Passed
Push — master ( acedf0...2adfe5 )
by Pauli
04:02
created

credentialsForUsernameAndPassword()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 3
nop 3
dl 0
loc 16
rs 10
c 0
b 0
f 0
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
		$expiryDate = \time() + $this->sessionExpiryTime;
98
		// TODO: The expiry timestamp is currently saved in the database as an unsigned integer.
99
		// For PostgreSQL, this has the maximum value of 2^31 which will become a problem in the
100
		// year 2038 (or already in 2037 if the admin has configured close to the maximum expiry time).
101
102
		$credentials = $this->checkHandshakeAuthentication($user, $timestamp, $auth);
103
		$session = $this->startNewSession($credentials['user'], $expiryDate, $version, $credentials['apiKeyId']);
104
		$controller->setSession($session);
105
	}
106
107
	private function checkHandshakeTimestamp(int $timestamp, int $currentTime) : void {
108
		if ($timestamp === 0) {
109
			throw new AmpacheException('Invalid Login - cannot parse time', 401);
110
		}
111
		if ($timestamp < ($currentTime - $this->sessionExpiryTime)) {
112
			throw new AmpacheException('Invalid Login - session is outdated', 401);
113
		}
114
		// Allow the timestamp to be at maximum 10 minutes in the future. The client may use its
115
		// own system clock to generate the timestamp and that may differ from the server's time.
116
		if ($timestamp > $currentTime + 600) {
117
			throw new AmpacheException('Invalid Login - timestamp is in future', 401);
118
		}
119
	}
120
121
	private function checkHandshakeAuthentication(?string $user, int $timestamp, ?string $auth) : array {
122
		if ($auth === null) {
123
			throw new AmpacheException('Invalid Login - required credentials missing', 401);
124
		}
125
126
		// The username is not passed by the client when the "API key" authentication is used
127
		if ($user === null) {
128
			$credentials = $this->credentialsForApiKey($auth);
129
		} else {
130
			$this->checkHandshakeTimestamp($timestamp, \time());
131
			$credentials = $this->credentialsForUsernameAndPassword($user, $timestamp, $auth);
132
		}
133
134
		if ($credentials === null) {
135
			throw new AmpacheException('Invalid Login - passphrase does not match', 401);
136
		}
137
138
		return $credentials;
139
	}
140
141
	private function credentialsForApiKey($auth) : ?array {
142
		$usersAndHashes = $this->ampacheUserMapper->getUsersAndPasswordHashes();
143
144
		foreach ($usersAndHashes as $keyId => $row) {
145
			// It's a bit vague in the API documentation, but looking at the Ampache source codes,
146
			// there are two valid options for passing the API key: either it is passed in plaintext,
147
			// or it's passed hashed together with the username like sha256(username . sha256(apiKey)).
148
			// On the other hand, our DB contains hashed keys sha256(apiKey).
149
			$valid1 = ($row['hash'] == \hash('sha256', $auth));
150
			$valid2 = ($auth == \hash('sha256', $row['user_id'] . $row['hash']));
151
152
			if ($valid1 || $valid2) {
153
				return ['user' => $row['user_id'], 'apiKeyId' => (int)$keyId];
154
			}
155
		}
156
157
		return null;
158
	}
159
160
	private function credentialsForUsernameAndPassword(string $user, int $timestamp, string $auth) : ?array {
161
		$user = $this->ampacheUserMapper->getProperUserId($user);
162
163
		if ($user !== null) {
164
			$hashes = $this->ampacheUserMapper->getPasswordHashes($user);
165
166
			foreach ($hashes as $keyId => $hash) {
167
				$expectedHash = \hash('sha256', $timestamp . $hash);
168
169
				if ($expectedHash === $auth) {
170
					return ['user' => $user, 'apiKeyId' => (int)$keyId];
171
				}
172
			}
173
		}
174
175
		return null;
176
	}
177
178
	private function startNewSession(string $user, int $expiryDate, ?string $apiVersion, int $apiKeyId) : AmpacheSession {
179
		// create new session
180
		$session = new AmpacheSession();
181
		$session->setUserId($user);
182
		$session->setToken(Random::secure(16));
183
		$session->setExpiry($expiryDate);
184
		$session->setApiVersion(Util::truncate($apiVersion, 16));
185
		$session->setAmpacheUserId($apiKeyId);
186
187
		// save session to the database
188
		$this->ampacheSessionMapper->insert($session);
189
190
		return $session;
191
	}
192
193
	private function handleNonHandshakeAction(AmpacheController $controller, ?string $action) : void {
194
		$token = $this->request->getParam('auth') ?: $this->request->getParam('ssid') ?: $this->getTokenFromHeader();
195
196
		// 'ping' is allowed without a session (but if session token is passed, then it has to be valid)
197
		if ($action === 'ping' && empty($token)) {
198
			return;
199
		}
200
201
		if ($token === 'internal') {
202
			$session = $this->getInternalSession();
203
		} else {
204
			$session = $this->getExistingSession($token);
205
		}
206
		$controller->setSession($session);
207
208
		if ($action === 'goodbye') {
209
			if ($token === 'internal') {
210
				throw new AmpacheException('Internal session cannot be terminated', 401);
211
			} else {
212
				$this->ampacheSessionMapper->delete($session);
213
			}
214
		}
215
216
		if ($action === null) {
217
			throw new AmpacheException("Required argument 'action' missing", 400);
218
		}
219
	}
220
221
	private function getTokenFromHeader() : ?string {
222
		// The Authorization header cannot be obtained with $this->request->getHeader(). Hence, we
223
		// use the native PHP API for this. Apparently, the getallheaders() function is not available
224
		// on non-Apache servers (e.g. nginx) prior to PHP 7.3.
225
		$authHeader = getallheaders()['Authorization'] ?? '';
226
		$prefix = 'Bearer ';
227
		if (Util::startsWith($authHeader, $prefix)) {
228
			return \substr($authHeader, \strlen($prefix));
229
		} else {
230
			return null;
231
		}
232
	}
233
234
	/**
235
	 * Internal session may be used to utilize the Ampache API within the Nextcloud/ownCloud server while in
236
	 * a valid user session, without needing to create an API key for this. That is, this session type is never
237
	 * used by the external client applications.
238
	 */
239
	private function getInternalSession() : AmpacheSession {
240
		if ($this->loggedInUser === null) {
241
			throw new AmpacheException('Internal session requires a logged-in cloud user', 401);
242
		}
243
244
		$session = new AmpacheSession();
245
		$session->userId = $this->loggedInUser;
246
		$session->token = 'internal';
247
		$session->expiry = 0;
248
		$session->apiVersion = AmpacheController::API6_VERSION;
249
		$session->ampacheUserId = 0;
250
251
		return $session;
252
	}
253
254
	private function getExistingSession(?string $token) : AmpacheSession {
255
		if (empty($token)) {
256
			throw new AmpacheException('Invalid Login - session token missing', 401);
257
		} else {
258
			try {
259
				// extend the session deadline on any authorized API call
260
				$this->ampacheSessionMapper->extend($token, \time() + $this->sessionExpiryTime);
261
				return $this->ampacheSessionMapper->findByToken($token);
262
			} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
263
				throw new AmpacheException('Invalid Login - invalid session token', 401);
264
			}
265
		}
266
	}
267
268
	/**
269
	 * If an AmpacheException is being caught, the appropriate ampache
270
	 * exception response is rendered
271
	 *
272
	 * NOTE: Type declarations cannot be used on this function signature because that would be
273
	 * in conflict with the base class which is not in our hands.
274
	 *
275
	 * @param Controller $controller the controller that is being called
276
	 * @param string $methodName the name of the method that will be called on
277
	 *                           the controller
278
	 * @param \Exception $exception the thrown exception
279
	 * @throws \Exception the passed in exception if it wasn't handled
280
	 * @return \OCP\AppFramework\Http\Response object if the exception was handled
281
	 */
282
	public function afterException($controller, $methodName, \Exception $exception) {
283
		if ($controller instanceof AmpacheController) {
284
			if ($exception instanceof AmpacheException) {
285
				return $controller->ampacheErrorResponse($exception->getCode(), $exception->getMessage());
286
			} elseif ($exception instanceof BusinessLayerException) {
287
				return $controller->ampacheErrorResponse(404, 'Entity not found');
288
			}
289
		}
290
		throw $exception;
291
	}
292
293
}
294