Passed
Push — master ( 65fd24...fc096a )
by Joas
17:08 queued 14s
created

PublicKeyTokenProvider::updatePasswords()   C

Complexity

Conditions 13
Paths 14

Size

Total Lines 42
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 4 Features 0
Metric Value
cc 13
eloc 23
c 4
b 4
f 0
nc 14
nop 2
dl 0
loc 42
rs 6.6166

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @copyright Copyright 2018, Roeland Jago Douma <[email protected]>
7
 *
8
 * @author Christoph Wurst <[email protected]>
9
 * @author Daniel Kesselberg <[email protected]>
10
 * @author Joas Schilling <[email protected]>
11
 * @author Morris Jobke <[email protected]>
12
 * @author Roeland Jago Douma <[email protected]>
13
 *
14
 * @license GNU AGPL version 3 or any later version
15
 *
16
 * This program is free software: you can redistribute it and/or modify
17
 * it under the terms of the GNU Affero General Public License as
18
 * published by the Free Software Foundation, either version 3 of the
19
 * License, or (at your option) any later version.
20
 *
21
 * This program is distributed in the hope that it will be useful,
22
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
23
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24
 * GNU Affero General Public License for more details.
25
 *
26
 * You should have received a copy of the GNU Affero General Public License
27
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
28
 *
29
 */
30
namespace OC\Authentication\Token;
31
32
use OC\Authentication\Exceptions\ExpiredTokenException;
33
use OC\Authentication\Exceptions\InvalidTokenException;
34
use OC\Authentication\Exceptions\TokenPasswordExpiredException;
35
use OC\Authentication\Exceptions\PasswordlessTokenException;
36
use OC\Authentication\Exceptions\WipeTokenException;
37
use OCP\AppFramework\Db\TTransactional;
38
use OCP\Cache\CappedMemoryCache;
39
use OCP\AppFramework\Db\DoesNotExistException;
40
use OCP\AppFramework\Utility\ITimeFactory;
41
use OCP\IConfig;
42
use OCP\IDBConnection;
43
use OCP\IUserManager;
44
use OCP\Security\ICrypto;
45
use OCP\Security\IHasher;
46
use Psr\Log\LoggerInterface;
47
48
class PublicKeyTokenProvider implements IProvider {
49
	use TTransactional;
50
51
	/** @var PublicKeyTokenMapper */
52
	private $mapper;
53
54
	/** @var ICrypto */
55
	private $crypto;
56
57
	/** @var IConfig */
58
	private $config;
59
60
	private IDBConnection $db;
61
62
	/** @var LoggerInterface */
63
	private $logger;
64
65
	/** @var ITimeFactory */
66
	private $time;
67
68
	/** @var CappedMemoryCache */
69
	private $cache;
70
71
	private IHasher $hasher;
72
73
	public function __construct(PublicKeyTokenMapper $mapper,
74
								ICrypto $crypto,
75
								IConfig $config,
76
								IDBConnection $db,
77
								LoggerInterface $logger,
78
								ITimeFactory $time,
79
								IHasher $hasher) {
80
		$this->mapper = $mapper;
81
		$this->crypto = $crypto;
82
		$this->config = $config;
83
		$this->db = $db;
84
		$this->logger = $logger;
85
		$this->time = $time;
86
87
		$this->cache = new CappedMemoryCache();
88
		$this->hasher = $hasher;
89
	}
90
91
	/**
92
	 * {@inheritDoc}
93
	 */
94
	public function generateToken(string $token,
95
								  string $uid,
96
								  string $loginName,
97
								  ?string $password,
98
								  string $name,
99
								  int $type = IToken::TEMPORARY_TOKEN,
100
								  int $remember = IToken::DO_NOT_REMEMBER): IToken {
101
		if (mb_strlen($name) > 128) {
102
			$name = mb_substr($name, 0, 120) . '…';
103
		}
104
105
		$dbToken = $this->newToken($token, $uid, $loginName, $password, $name, $type, $remember);
106
		$this->mapper->insert($dbToken);
107
108
		// Add the token to the cache
109
		$this->cache[$dbToken->getToken()] = $dbToken;
110
111
		return $dbToken;
112
	}
113
114
	public function getToken(string $tokenId): IToken {
115
		$tokenHash = $this->hashToken($tokenId);
116
117
		if (isset($this->cache[$tokenHash])) {
118
			if ($this->cache[$tokenHash] instanceof DoesNotExistException) {
119
				$ex = $this->cache[$tokenHash];
120
				throw new InvalidTokenException("Token does not exist: " . $ex->getMessage(), 0, $ex);
121
			}
122
			$token = $this->cache[$tokenHash];
123
		} else {
124
			try {
125
				$token = $this->mapper->getToken($this->hashToken($tokenId));
126
				$this->cache[$token->getToken()] = $token;
127
			} catch (DoesNotExistException $ex) {
128
				try {
129
					$token = $this->mapper->getToken($this->hashTokenWithEmptySecret($tokenId));
130
					$this->cache[$token->getToken()] = $token;
131
					$this->rotate($token, $tokenId, $tokenId);
132
				} catch (DoesNotExistException $ex2) {
133
					$this->cache[$tokenHash] = $ex2;
134
					throw new InvalidTokenException("Token does not exist: " . $ex->getMessage(), 0, $ex);
135
				}
136
			}
137
		}
138
139
		if ((int)$token->getExpires() !== 0 && $token->getExpires() < $this->time->getTime()) {
140
			throw new ExpiredTokenException($token);
141
		}
142
143
		if ($token->getType() === IToken::WIPE_TOKEN) {
144
			throw new WipeTokenException($token);
145
		}
146
147
		if ($token->getPasswordInvalid() === true) {
148
			//The password is invalid we should throw an TokenPasswordExpiredException
149
			throw new TokenPasswordExpiredException($token);
150
		}
151
152
		return $token;
153
	}
154
155
	public function getTokenById(int $tokenId): IToken {
156
		try {
157
			$token = $this->mapper->getTokenById($tokenId);
158
		} catch (DoesNotExistException $ex) {
159
			throw new InvalidTokenException("Token with ID $tokenId does not exist: " . $ex->getMessage(), 0, $ex);
160
		}
161
162
		if ((int)$token->getExpires() !== 0 && $token->getExpires() < $this->time->getTime()) {
163
			throw new ExpiredTokenException($token);
164
		}
165
166
		if ($token->getType() === IToken::WIPE_TOKEN) {
167
			throw new WipeTokenException($token);
168
		}
169
170
		if ($token->getPasswordInvalid() === true) {
171
			//The password is invalid we should throw an TokenPasswordExpiredException
172
			throw new TokenPasswordExpiredException($token);
173
		}
174
175
		return $token;
176
	}
177
178
	public function renewSessionToken(string $oldSessionId, string $sessionId): IToken {
179
		$this->cache->clear();
180
181
		return $this->atomic(function () use ($oldSessionId, $sessionId) {
182
			$token = $this->getToken($oldSessionId);
183
184
			if (!($token instanceof PublicKeyToken)) {
0 ignored issues
show
introduced by
$token is always a sub-type of OC\Authentication\Token\PublicKeyToken.
Loading history...
185
				throw new InvalidTokenException("Invalid token type");
186
			}
187
188
			$password = null;
189
			if (!is_null($token->getPassword())) {
190
				$privateKey = $this->decrypt($token->getPrivateKey(), $oldSessionId);
191
				$password = $this->decryptPassword($token->getPassword(), $privateKey);
192
			}
193
			$newToken = $this->generateToken(
194
				$sessionId,
195
				$token->getUID(),
196
				$token->getLoginName(),
197
				$password,
198
				$token->getName(),
199
				IToken::TEMPORARY_TOKEN,
200
				$token->getRemember()
201
			);
202
203
			$this->mapper->delete($token);
204
205
			return $newToken;
206
		}, $this->db);
207
	}
208
209
	public function invalidateToken(string $token) {
210
		$this->cache->clear();
211
212
		$this->mapper->invalidate($this->hashToken($token));
213
		$this->mapper->invalidate($this->hashTokenWithEmptySecret($token));
214
	}
215
216
	public function invalidateTokenById(string $uid, int $id) {
217
		$this->cache->clear();
218
219
		$this->mapper->deleteById($uid, $id);
220
	}
221
222
	public function invalidateOldTokens() {
223
		$this->cache->clear();
224
225
		$olderThan = $this->time->getTime() - (int) $this->config->getSystemValue('session_lifetime', 60 * 60 * 24);
226
		$this->logger->debug('Invalidating session tokens older than ' . date('c', $olderThan), ['app' => 'cron']);
227
		$this->mapper->invalidateOld($olderThan, IToken::DO_NOT_REMEMBER);
228
		$rememberThreshold = $this->time->getTime() - (int) $this->config->getSystemValue('remember_login_cookie_lifetime', 60 * 60 * 24 * 15);
229
		$this->logger->debug('Invalidating remembered session tokens older than ' . date('c', $rememberThreshold), ['app' => 'cron']);
230
		$this->mapper->invalidateOld($rememberThreshold, IToken::REMEMBER);
231
	}
232
233
	public function updateToken(IToken $token) {
234
		$this->cache->clear();
235
236
		if (!($token instanceof PublicKeyToken)) {
237
			throw new InvalidTokenException("Invalid token type");
238
		}
239
		$this->mapper->update($token);
240
	}
241
242
	public function updateTokenActivity(IToken $token) {
243
		$this->cache->clear();
244
245
		if (!($token instanceof PublicKeyToken)) {
246
			throw new InvalidTokenException("Invalid token type");
247
		}
248
249
		$activityInterval = $this->config->getSystemValueInt('token_auth_activity_update', 60);
250
		$activityInterval = min(max($activityInterval, 0), 300);
251
252
		/** @var PublicKeyToken $token */
253
		$now = $this->time->getTime();
254
		if ($token->getLastActivity() < ($now - $activityInterval)) {
255
			$token->setLastActivity($now);
256
			$this->mapper->updateActivity($token, $now);
257
		}
258
	}
259
260
	public function getTokenByUser(string $uid): array {
261
		return $this->mapper->getTokenByUser($uid);
262
	}
263
264
	public function getPassword(IToken $savedToken, string $tokenId): string {
265
		if (!($savedToken instanceof PublicKeyToken)) {
266
			throw new InvalidTokenException("Invalid token type");
267
		}
268
269
		if ($savedToken->getPassword() === null) {
270
			throw new PasswordlessTokenException();
271
		}
272
273
		// Decrypt private key with tokenId
274
		$privateKey = $this->decrypt($savedToken->getPrivateKey(), $tokenId);
275
276
		// Decrypt password with private key
277
		return $this->decryptPassword($savedToken->getPassword(), $privateKey);
278
	}
279
280
	public function setPassword(IToken $token, string $tokenId, string $password) {
281
		$this->cache->clear();
282
283
		if (!($token instanceof PublicKeyToken)) {
284
			throw new InvalidTokenException("Invalid token type");
285
		}
286
287
		// When changing passwords all temp tokens are deleted
288
		$this->mapper->deleteTempToken($token);
289
290
		// Update the password for all tokens
291
		$tokens = $this->mapper->getTokenByUser($token->getUID());
292
		foreach ($tokens as $t) {
293
			$publicKey = $t->getPublicKey();
294
			$t->setPassword($this->encryptPassword($password, $publicKey));
295
			$t->setPasswordHash($this->hashPassword($password));
296
			$this->updateToken($t);
297
		}
298
	}
299
300
	private function hashPassword(string $password): string {
301
		return $this->hasher->hash(sha1($password) . $password);
302
	}
303
304
	public function rotate(IToken $token, string $oldTokenId, string $newTokenId): IToken {
305
		$this->cache->clear();
306
307
		if (!($token instanceof PublicKeyToken)) {
308
			throw new InvalidTokenException("Invalid token type");
309
		}
310
311
		// Decrypt private key with oldTokenId
312
		$privateKey = $this->decrypt($token->getPrivateKey(), $oldTokenId);
313
		// Encrypt with the new token
314
		$token->setPrivateKey($this->encrypt($privateKey, $newTokenId));
315
316
		$token->setToken($this->hashToken($newTokenId));
317
		$this->updateToken($token);
318
319
		return $token;
320
	}
321
322
	private function encrypt(string $plaintext, string $token): string {
323
		$secret = $this->config->getSystemValue('secret');
324
		return $this->crypto->encrypt($plaintext, $token . $secret);
325
	}
326
327
	/**
328
	 * @throws InvalidTokenException
329
	 */
330
	private function decrypt(string $cipherText, string $token): string {
331
		$secret = $this->config->getSystemValue('secret');
332
		try {
333
			return $this->crypto->decrypt($cipherText, $token . $secret);
334
		} catch (\Exception $ex) {
335
			// Retry with empty secret as a fallback for instances where the secret might not have been set by accident
336
			try {
337
				return $this->crypto->decrypt($cipherText, $token);
338
			} catch (\Exception $ex2) {
339
				// Delete the invalid token
340
				$this->invalidateToken($token);
341
				throw new InvalidTokenException("Could not decrypt token password: " . $ex->getMessage(), 0, $ex2);
342
			}
343
		}
344
	}
345
346
	private function encryptPassword(string $password, string $publicKey): string {
347
		openssl_public_encrypt($password, $encryptedPassword, $publicKey, OPENSSL_PKCS1_OAEP_PADDING);
348
		$encryptedPassword = base64_encode($encryptedPassword);
349
350
		return $encryptedPassword;
351
	}
352
353
	private function decryptPassword(string $encryptedPassword, string $privateKey): string {
354
		$encryptedPassword = base64_decode($encryptedPassword);
355
		openssl_private_decrypt($encryptedPassword, $password, $privateKey, OPENSSL_PKCS1_OAEP_PADDING);
356
357
		return $password;
358
	}
359
360
	private function hashToken(string $token): string {
361
		$secret = $this->config->getSystemValue('secret');
362
		return hash('sha512', $token . $secret);
363
	}
364
365
	/**
366
	 * @deprecated Fallback for instances where the secret might not have been set by accident
367
	 */
368
	private function hashTokenWithEmptySecret(string $token): string {
369
		return hash('sha512', $token);
370
	}
371
372
	/**
373
	 * @throws \RuntimeException when OpenSSL reports a problem
374
	 */
375
	private function newToken(string $token,
376
							  string $uid,
377
							  string $loginName,
378
							  $password,
379
							  string $name,
380
							  int $type,
381
							  int $remember): PublicKeyToken {
382
		$dbToken = new PublicKeyToken();
383
		$dbToken->setUid($uid);
384
		$dbToken->setLoginName($loginName);
385
386
		$config = array_merge([
387
			'digest_alg' => 'sha512',
388
			'private_key_bits' => $password !== null && strlen($password) > 250 ? 4096 : 2048,
389
		], $this->config->getSystemValue('openssl', []));
390
391
		// Generate new key
392
		$res = openssl_pkey_new($config);
393
		if ($res === false) {
394
			$this->logOpensslError();
395
			throw new \RuntimeException('OpenSSL reported a problem');
396
		}
397
398
		if (openssl_pkey_export($res, $privateKey, null, $config) === false) {
399
			$this->logOpensslError();
400
			throw new \RuntimeException('OpenSSL reported a problem');
401
		}
402
403
		// Extract the public key from $res to $pubKey
404
		$publicKey = openssl_pkey_get_details($res);
405
		$publicKey = $publicKey['key'];
406
407
		$dbToken->setPublicKey($publicKey);
408
		$dbToken->setPrivateKey($this->encrypt($privateKey, $token));
409
410
		if (!is_null($password) && $this->config->getSystemValueBool('auth.storeCryptedPassword', true)) {
411
			if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) {
412
				throw new \RuntimeException('Trying to save a password with more than 469 characters is not supported. If you want to use big passwords, disable the auth.storeCryptedPassword option in config.php');
413
			}
414
			$dbToken->setPassword($this->encryptPassword($password, $publicKey));
415
			$dbToken->setPasswordHash($this->hashPassword($password));
416
		}
417
418
		$dbToken->setName($name);
419
		$dbToken->setToken($this->hashToken($token));
420
		$dbToken->setType($type);
421
		$dbToken->setRemember($remember);
422
		$dbToken->setLastActivity($this->time->getTime());
423
		$dbToken->setLastCheck($this->time->getTime());
424
		$dbToken->setVersion(PublicKeyToken::VERSION);
425
426
		return $dbToken;
427
	}
428
429
	public function markPasswordInvalid(IToken $token, string $tokenId) {
430
		$this->cache->clear();
431
432
		if (!($token instanceof PublicKeyToken)) {
433
			throw new InvalidTokenException("Invalid token type");
434
		}
435
436
		$token->setPasswordInvalid(true);
437
		$this->mapper->update($token);
438
	}
439
440
	public function updatePasswords(string $uid, string $password) {
441
		$this->cache->clear();
442
443
		// prevent setting an empty pw as result of pw-less-login
444
		if ($password === '' || !$this->config->getSystemValueBool('auth.storeCryptedPassword', true)) {
445
			return;
446
		}
447
448
		// Update the password for all tokens
449
		$tokens = $this->mapper->getTokenByUser($uid);
450
		$newPasswordHash = null;
451
452
		/**
453
		 * - true: The password hash could not be verified anymore
454
		 *     and the token needs to be updated with the newly encrypted password
455
		 * - false: The hash could still be verified
456
		 * - missing: The hash needs to be verified
457
		 */
458
		$hashNeedsUpdate = [];
459
460
		foreach ($tokens as $t) {
461
			if (!isset($hashNeedsUpdate[$t->getPasswordHash()])) {
462
				if ($t->getPasswordHash() === null) {
463
					$hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true;
464
				} elseif (!$this->hasher->verify(sha1($password) . $password, $t->getPasswordHash())) {
465
					$hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true;
466
				} else {
467
					$hashNeedsUpdate[$t->getPasswordHash() ?: ''] = false;
468
				}
469
			}
470
			$needsUpdating = $hashNeedsUpdate[$t->getPasswordHash() ?: ''] ?? true;
471
472
			if ($needsUpdating) {
473
				if ($newPasswordHash === null) {
474
					$newPasswordHash = $this->hashPassword($password);
475
				}
476
477
				$publicKey = $t->getPublicKey();
478
				$t->setPassword($this->encryptPassword($password, $publicKey));
479
				$t->setPasswordHash($newPasswordHash);
480
				$t->setPasswordInvalid(false);
481
				$this->updateToken($t);
482
			}
483
		}
484
	}
485
486
	private function logOpensslError() {
487
		$errors = [];
488
		while ($error = openssl_error_string()) {
489
			$errors[] = $error;
490
		}
491
		$this->logger->critical('Something is wrong with your openssl setup: ' . implode(', ', $errors));
492
	}
493
}
494