Passed
Push — master ( 83669c...348a90 )
by Pauli
07:30 queued 04:27
created

AmpacheMiddleware::getExistingSession()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 8
c 0
b 0
f 0
nc 4
nop 1
dl 0
loc 10
rs 10
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 - 2023
13
 */
14
15
namespace OCA\Music\Middleware;
16
17
use OCP\IRequest;
18
use OCP\AppFramework\Controller;
19
use OCP\AppFramework\Middleware;
20
21
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
22
use OCA\Music\AppFramework\Core\Logger;
23
use OCA\Music\Controller\AmpacheController;
24
use OCA\Music\Db\AmpacheSession;
25
use OCA\Music\Db\AmpacheSessionMapper;
26
use OCA\Music\Db\AmpacheUserMapper;
27
use OCA\Music\Utility\Random;
28
use OCA\Music\Utility\Util;
29
30
/**
31
 * Handles the session management on Ampache login/logout.
32
 * Checks the authentication on each Ampache API call before the
33
 * request is allowed to be passed to AmpacheController.
34
 * Map identified exceptions from the controller to proper Ampache error results.
35
 */
36
class AmpacheMiddleware extends Middleware {
37
38
	const SESSION_EXPIRY_TIME = 6000;
39
40
	private $request;
41
	private $ampacheSessionMapper;
42
	private $ampacheUserMapper;
43
	private $logger;
44
45
	public function __construct(
46
			IRequest $request, AmpacheSessionMapper $ampacheSessionMapper, AmpacheUserMapper $ampacheUserMapper, Logger $logger) {
47
		$this->request = $request;
48
		$this->ampacheSessionMapper = $ampacheSessionMapper;
49
		$this->ampacheUserMapper = $ampacheUserMapper;
50
		$this->logger = $logger;
51
	}
52
53
	/**
54
	 * This runs all the security checks before a method call. The
55
	 * security checks are determined by inspecting the controller method
56
	 * annotations
57
	 *
58
	 * NOTE: Type declarations cannot be used on this function signature because that would be
59
	 * in conflict with the base class which is not in our hands.
60
	 *
61
	 * @param Controller $controller the controller that is being called
62
	 * @param string $methodName the name of the method
63
	 * @throws AmpacheException when a security check fails
64
	 */
65
	public function beforeController($controller, $methodName) {
66
		if ($controller instanceof AmpacheController) {
67
			if ($methodName === 'jsonApi') {
68
				$controller->setJsonMode(true);
69
			}
70
71
			// authenticate on 'handshake' and check the session token on any other action
72
			$action = $this->request->getParam('action');
73
			if ($action === 'handshake') {
74
				$this->handleHandshake($controller);
75
			} else {
76
				$this->handleNonHandshakeAction($controller, $action);
77
			}
78
		}
79
	}
80
	
81
	private function handleHandshake(AmpacheController $controller) : void {
82
		$user = $this->request->getParam('user');
83
		$timestamp = (int)$this->request->getParam('timestamp');
84
		$auth = $this->request->getParam('auth');
85
		$version = $this->request->getParam('version');
86
87
		$currentTime = \time();
88
		$expiryDate = $currentTime + self::SESSION_EXPIRY_TIME;
89
90
		$this->checkHandshakeTimestamp($timestamp, $currentTime);
91
		$apiKeyId = $this->checkHandshakeAuthentication($user, $timestamp, $auth);
92
		$session = $this->startNewSession($user, $expiryDate, $version, $apiKeyId);
93
		$controller->setSession($session);
94
	}
95
96
	private function checkHandshakeTimestamp(int $timestamp, int $currentTime) : void {
97
		if ($timestamp === 0) {
98
			throw new AmpacheException('Invalid Login - cannot parse time', 401);
99
		}
100
		if ($timestamp < ($currentTime - self::SESSION_EXPIRY_TIME)) {
101
			throw new AmpacheException('Invalid Login - session is outdated', 401);
102
		}
103
		// Allow the timestamp to be at maximum 10 minutes in the future. The client may use its
104
		// own system clock to generate the timestamp and that may differ from the server's time.
105
		if ($timestamp > $currentTime + 600) {
106
			throw new AmpacheException('Invalid Login - timestamp is in future', 401);
107
		}
108
	}
109
110
	private function checkHandshakeAuthentication(?string $user, int $timestamp, ?string $auth) : int {
111
		if ($user === null || $auth === null) {
112
			throw new AmpacheException('Invalid Login - required credentials missing', 401);
113
		}
114
115
		$hashes = $this->ampacheUserMapper->getPasswordHashes($user);
116
117
		foreach ($hashes as $keyId => $hash) {
118
			$expectedHash = \hash('sha256', $timestamp . $hash);
119
120
			if ($expectedHash === $auth) {
121
				return (int)$keyId;
122
			}
123
		}
124
125
		throw new AmpacheException('Invalid Login - passphrase does not match', 401);
126
	}
127
128
	private function startNewSession(string $user, int $expiryDate, ?string $apiVersion, int $apiKeyId) : AmpacheSession {
129
		// create new session
130
		$session = new AmpacheSession();
131
		$session->setUserId($user);
132
		$session->setToken(Random::secure(16));
133
		$session->setExpiry($expiryDate);
134
		$session->setApiVersion(Util::truncate($apiVersion, 16));
135
		$session->setAmpacheUserId($apiKeyId);
136
137
		// save session to the database
138
		$this->ampacheSessionMapper->insert($session);
139
140
		return $session;
141
	}
142
143
	private function handleNonHandshakeAction(AmpacheController $controller, ?string $action) : void {
144
		$token = $this->request->getParam('auth') ?: $this->request->getParam('ssid');
145
146
		// 'ping' is allowed without a session (but if session token is passed, then it has to be valid)
147
		if ($action === 'ping' && empty($token)) {
148
			return;
149
		}
150
151
		$session = $this->getExistingSession($token);
152
		$controller->setSession($session);
153
154
		if ($action === 'goodbye') {
155
			$this->ampacheSessionMapper->delete($session);
156
		}
157
158
		if ($action === null) {
159
			throw new AmpacheException("Required argument 'action' missing", 400);
160
		}
161
	}
162
163
	private function getExistingSession(?string $token) : AmpacheSession {
164
		if (empty($token)) {
165
			throw new AmpacheException('Invalid Login - session token missing', 401);
166
		} else {
167
			try {
168
				// extend the session deadline on any authorized API call
169
				$this->ampacheSessionMapper->extend($token, \time() + self::SESSION_EXPIRY_TIME);
170
				return $this->ampacheSessionMapper->findByToken($token);
171
			} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
172
				throw new AmpacheException('Invalid Login - invalid session token', 401);
173
			}
174
		}
175
	}
176
177
	/**
178
	 * If an AmpacheException is being caught, the appropiate ampache
179
	 * exception response is rendered
180
	 *
181
	 * NOTE: Type declarations cannot be used on this function signature because that would be
182
	 * in conflict with the base class which is not in our hands.
183
	 *
184
	 * @param Controller $controller the controller that is being called
185
	 * @param string $methodName the name of the method that will be called on
186
	 *                           the controller
187
	 * @param \Exception $exception the thrown exception
188
	 * @throws \Exception the passed in exception if it wasn't handled
189
	 * @return \OCP\AppFramework\Http\Response object if the exception was handled
190
	 */
191
	public function afterException($controller, $methodName, \Exception $exception) {
192
		if ($controller instanceof AmpacheController) {
193
			if ($exception instanceof AmpacheException) {
194
				return $controller->ampacheErrorResponse($exception->getCode(), $exception->getMessage());
195
			} elseif ($exception instanceof BusinessLayerException) {
196
				return $controller->ampacheErrorResponse(404, 'Entity not found');
197
			}
198
		}
199
		throw $exception;
200
	}
201
202
}
203