Passed
Pull Request — master (#1172)
by Pauli
10:14 queued 07:20
created

AmpacheMiddleware::getInternalSession()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 9
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 13
rs 9.9666
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2018 - 2024
13
 */
14
15
namespace OCA\Music\Middleware;
16
17
use OCP\IConfig;
18
use OCP\IRequest;
19
use OCP\AppFramework\Controller;
20
use OCP\AppFramework\Middleware;
21
22
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
23
use OCA\Music\AppFramework\Core\Logger;
24
use OCA\Music\Controller\AmpacheController;
25
use OCA\Music\Db\AmpacheSession;
26
use OCA\Music\Db\AmpacheSessionMapper;
27
use OCA\Music\Db\AmpacheUserMapper;
28
use OCA\Music\Utility\Random;
29
use OCA\Music\Utility\Util;
30
31
/**
32
 * Handles the session management on Ampache login/logout.
33
 * Checks the authentication on each Ampache API call before the
34
 * request is allowed to be passed to AmpacheController.
35
 * Map identified exceptions from the controller to proper Ampache error results.
36
 */
37
class AmpacheMiddleware extends Middleware {
38
39
	private $request;
40
	private $ampacheSessionMapper;
41
	private $ampacheUserMapper;
42
	private $logger;
43
	private $loggedInUser;
44
	private $sessionExpiryTime;
45
46
	public function __construct(
47
			IRequest $request,
48
			IConfig $config,
49
			AmpacheSessionMapper $ampacheSessionMapper,
50
			AmpacheUserMapper $ampacheUserMapper,
51
			Logger $logger,
52
			?string $userId) {
53
		$this->request = $request;
54
		$this->ampacheSessionMapper = $ampacheSessionMapper;
55
		$this->ampacheUserMapper = $ampacheUserMapper;
56
		$this->logger = $logger;
57
		$this->loggedInUser = $userId;
58
59
		$this->sessionExpiryTime = $config->getSystemValue('music.ampache_session_expiry_time', 6000);
60
		$this->sessionExpiryTime = \min($this->sessionExpiryTime, 365*24*60*60); // limit to one year
61
	}
62
63
	/**
64
	 * This runs all the security checks before a method call. The
65
	 * security checks are determined by inspecting the controller method
66
	 * annotations
67
	 *
68
	 * NOTE: Type declarations cannot be used on this function signature because that would be
69
	 * in conflict with the base class which is not in our hands.
70
	 *
71
	 * @param Controller $controller the controller that is being called
72
	 * @param string $methodName the name of the method
73
	 * @throws AmpacheException when a security check fails
74
	 */
75
	public function beforeController($controller, $methodName) {
76
		if ($controller instanceof AmpacheController) {
77
			if ($methodName === 'jsonApi') {
78
				$controller->setJsonMode(true);
79
			}
80
81
			// authenticate on 'handshake' and check the session token on any other action
82
			$action = $this->request->getParam('action');
83
			if ($action === 'handshake') {
84
				$this->handleHandshake($controller);
85
			} else {
86
				$this->handleNonHandshakeAction($controller, $action);
87
			}
88
		}
89
	}
90
	
91
	private function handleHandshake(AmpacheController $controller) : void {
92
		$user = $this->request->getParam('user');
93
		$timestamp = (int)$this->request->getParam('timestamp');
94
		$auth = $this->request->getParam('auth');
95
		$version = $this->request->getParam('version');
96
97
		$currentTime = \time();
98
		$expiryDate = $currentTime + $this->sessionExpiryTime;
99
		// TODO: The expiry timestamp is currently saved in the database as an unsigned integer.
100
		// For PostgreSQL, this has the maximum value of 2^31 which will become a problem in the
101
		// year 2038 (or already in 2037 if the admin has configured close to the maximum expiry time).
102
103
		$this->checkHandshakeTimestamp($timestamp, $currentTime);
104
		$credentials = $this->checkHandshakeAuthentication($user, $timestamp, $auth);
105
		$session = $this->startNewSession($credentials['user'], $expiryDate, $version, $credentials['apiKeyId']);
106
		$controller->setSession($session);
107
	}
108
109
	private function checkHandshakeTimestamp(int $timestamp, int $currentTime) : void {
110
		if ($timestamp === 0) {
111
			throw new AmpacheException('Invalid Login - cannot parse time', 401);
112
		}
113
		if ($timestamp < ($currentTime - $this->sessionExpiryTime)) {
114
			throw new AmpacheException('Invalid Login - session is outdated', 401);
115
		}
116
		// Allow the timestamp to be at maximum 10 minutes in the future. The client may use its
117
		// own system clock to generate the timestamp and that may differ from the server's time.
118
		if ($timestamp > $currentTime + 600) {
119
			throw new AmpacheException('Invalid Login - timestamp is in future', 401);
120
		}
121
	}
122
123
	private function checkHandshakeAuthentication(?string $user, int $timestamp, ?string $auth) : array {
124
		if ($user === null || $auth === null) {
125
			throw new AmpacheException('Invalid Login - required credentials missing', 401);
126
		}
127
128
		$user = $this->ampacheUserMapper->getProperUserId($user);
129
130
		if ($user !== null) {
131
			$hashes = $this->ampacheUserMapper->getPasswordHashes($user);
132
133
			foreach ($hashes as $keyId => $hash) {
134
				$expectedHash = \hash('sha256', $timestamp . $hash);
135
136
				if ($expectedHash === $auth) {
137
					return ['user' => $user, 'apiKeyId' => (int)$keyId];
138
				}
139
			}
140
		}
141
142
		throw new AmpacheException('Invalid Login - passphrase does not match', 401);
143
	}
144
145
	private function startNewSession(string $user, int $expiryDate, ?string $apiVersion, int $apiKeyId) : AmpacheSession {
146
		// create new session
147
		$session = new AmpacheSession();
148
		$session->setUserId($user);
149
		$session->setToken(Random::secure(16));
150
		$session->setExpiry($expiryDate);
151
		$session->setApiVersion(Util::truncate($apiVersion, 16));
152
		$session->setAmpacheUserId($apiKeyId);
153
154
		// save session to the database
155
		$this->ampacheSessionMapper->insert($session);
156
157
		return $session;
158
	}
159
160
	private function handleNonHandshakeAction(AmpacheController $controller, ?string $action) : void {
161
		$token = $this->request->getParam('auth') ?: $this->request->getParam('ssid') ?: $this->getTokenFromHeader();
162
163
		// 'ping' is allowed without a session (but if session token is passed, then it has to be valid)
164
		if ($action === 'ping' && empty($token)) {
165
			return;
166
		}
167
168
		if ($token === 'internal') {
169
			$session = $this->getInternalSession();
170
		} else {
171
			$session = $this->getExistingSession($token);
172
		}
173
		$controller->setSession($session);
174
175
		if ($action === 'goodbye') {
176
			if ($token === 'internal') {
177
				throw new AmpacheException('Internal session cannot be terminated', 401);
178
			} else {
179
				$this->ampacheSessionMapper->delete($session);
180
			}
181
		}
182
183
		if ($action === null) {
184
			throw new AmpacheException("Required argument 'action' missing", 400);
185
		}
186
	}
187
188
	private function getTokenFromHeader() : ?string {
189
		// The Authorization header cannot be obtained with $this->request->getHeader(). Hence, we
190
		// use the native PHP API for this. Apparently, the getallheaders() function is not available
191
		// on non-Apache servers (e.g. nginx) prior to PHP 7.3.
192
		$authHeader = getallheaders()['Authorization'] ?? '';
193
		$prefix = 'Bearer ';
194
		if (Util::startsWith($authHeader, $prefix)) {
195
			return \substr($authHeader, \strlen($prefix));
196
		} else {
197
			return null;
198
		}
199
	}
200
201
	/**
202
	 * Internal session may be used to utilize the Ampache API within the Nextcloud/ownCloud server while in
203
	 * a valid user session, without needing to create an API key for this. That is, this session type is never
204
	 * used by the external client applications.
205
	 */
206
	private function getInternalSession() : AmpacheSession {
207
		if ($this->loggedInUser === null) {
208
			throw new AmpacheException('Internal session requires a logged-in cloud user', 401);
209
		}
210
211
		$session = new AmpacheSession();
212
		$session->userId = $this->loggedInUser;
213
		$session->token = 'internal';
214
		$session->expiry = 0;
215
		$session->apiVersion = AmpacheController::API6_VERSION;
216
		$session->ampacheUserId = 0;
217
218
		return $session;
219
	}
220
221
	private function getExistingSession(?string $token) : AmpacheSession {
222
		if (empty($token)) {
223
			throw new AmpacheException('Invalid Login - session token missing', 401);
224
		} else {
225
			try {
226
				// extend the session deadline on any authorized API call
227
				$this->ampacheSessionMapper->extend($token, \time() + $this->sessionExpiryTime);
228
				return $this->ampacheSessionMapper->findByToken($token);
229
			} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
230
				throw new AmpacheException('Invalid Login - invalid session token', 401);
231
			}
232
		}
233
	}
234
235
	/**
236
	 * If an AmpacheException is being caught, the appropriate ampache
237
	 * exception response is rendered
238
	 *
239
	 * NOTE: Type declarations cannot be used on this function signature because that would be
240
	 * in conflict with the base class which is not in our hands.
241
	 *
242
	 * @param Controller $controller the controller that is being called
243
	 * @param string $methodName the name of the method that will be called on
244
	 *                           the controller
245
	 * @param \Exception $exception the thrown exception
246
	 * @throws \Exception the passed in exception if it wasn't handled
247
	 * @return \OCP\AppFramework\Http\Response object if the exception was handled
248
	 */
249
	public function afterException($controller, $methodName, \Exception $exception) {
250
		if ($controller instanceof AmpacheController) {
251
			if ($exception instanceof AmpacheException) {
252
				return $controller->ampacheErrorResponse($exception->getCode(), $exception->getMessage());
253
			} elseif ($exception instanceof BusinessLayerException) {
254
				return $controller->ampacheErrorResponse(404, 'Entity not found');
255
			}
256
		}
257
		throw $exception;
258
	}
259
260
}
261