Passed
Push — master ( f0b6a6...e47d56 )
by Joas
14:01 queued 14s
created

PublicKeyTokenProvider::generateToken()   B

Complexity

Conditions 7
Paths 17

Size

Total Lines 38
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 2 Features 0
Metric Value
cc 7
eloc 16
c 2
b 2
f 0
nc 17
nop 7
dl 0
loc 38
rs 8.8333
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
	public const TOKEN_MIN_LENGTH = 22;
50
51
	use TTransactional;
52
53
	/** @var PublicKeyTokenMapper */
54
	private $mapper;
55
56
	/** @var ICrypto */
57
	private $crypto;
58
59
	/** @var IConfig */
60
	private $config;
61
62
	private IDBConnection $db;
63
64
	/** @var LoggerInterface */
65
	private $logger;
66
67
	/** @var ITimeFactory */
68
	private $time;
69
70
	/** @var CappedMemoryCache */
71
	private $cache;
72
73
	private IHasher $hasher;
74
75
	public function __construct(PublicKeyTokenMapper $mapper,
76
								ICrypto $crypto,
77
								IConfig $config,
78
								IDBConnection $db,
79
								LoggerInterface $logger,
80
								ITimeFactory $time,
81
								IHasher $hasher) {
82
		$this->mapper = $mapper;
83
		$this->crypto = $crypto;
84
		$this->config = $config;
85
		$this->db = $db;
86
		$this->logger = $logger;
87
		$this->time = $time;
88
89
		$this->cache = new CappedMemoryCache();
90
		$this->hasher = $hasher;
91
	}
92
93
	/**
94
	 * {@inheritDoc}
95
	 */
96
	public function generateToken(string $token,
97
								  string $uid,
98
								  string $loginName,
99
								  ?string $password,
100
								  string $name,
101
								  int $type = IToken::TEMPORARY_TOKEN,
102
								  int $remember = IToken::DO_NOT_REMEMBER): IToken {
103
		if (strlen($token) < self::TOKEN_MIN_LENGTH) {
104
			$exception = new InvalidTokenException('Token is too short, minimum of ' . self::TOKEN_MIN_LENGTH . ' characters is required, ' . strlen($token) . ' characters given');
105
			$this->logger->error('Invalid token provided when generating new token', ['exception' => $exception]);
106
			throw $exception;
107
		}
108
109
		if (mb_strlen($name) > 128) {
110
			$name = mb_substr($name, 0, 120) . '…';
111
		}
112
113
		// We need to check against one old token to see if there is a password
114
		// hash that we can reuse for detecting outdated passwords
115
		$randomOldToken = $this->mapper->getFirstTokenForUser($uid);
116
		$oldTokenMatches = $randomOldToken && $this->hasher->verify(sha1($password) . $password, $randomOldToken->getPasswordHash());
117
118
		$dbToken = $this->newToken($token, $uid, $loginName, $password, $name, $type, $remember);
119
120
		if ($oldTokenMatches) {
121
			$dbToken->setPasswordHash($randomOldToken->getPasswordHash());
122
		}
123
124
		$this->mapper->insert($dbToken);
125
126
		if (!$oldTokenMatches && $password !== null) {
127
			$this->updatePasswords($uid, $password);
128
		}
129
130
		// Add the token to the cache
131
		$this->cache[$dbToken->getToken()] = $dbToken;
132
133
		return $dbToken;
134
	}
135
136
	public function getToken(string $tokenId): IToken {
137
		/**
138
		 * Token length: 72
139
		 * @see \OC\Core\Controller\ClientFlowLoginController::generateAppPassword
140
		 * @see \OC\Core\Controller\AppPasswordController::getAppPassword
141
		 * @see \OC\Core\Command\User\AddAppPassword::execute
142
		 * @see \OC\Core\Service\LoginFlowV2Service::flowDone
143
		 * @see \OCA\Talk\MatterbridgeManager::generatePassword
144
		 * @see \OCA\Preferred_Providers\Controller\PasswordController::generateAppPassword
145
		 * @see \OCA\GlobalSiteSelector\TokenHandler::generateAppPassword
146
		 *
147
		 * Token length: 22-256 - https://www.php.net/manual/en/session.configuration.php#ini.session.sid-length
148
		 * @see \OC\User\Session::createSessionToken
149
		 *
150
		 * Token length: 29
151
		 * @see \OCA\Settings\Controller\AuthSettingsController::generateRandomDeviceToken
152
		 * @see \OCA\Registration\Service\RegistrationService::generateAppPassword
153
		 */
154
		if (strlen($tokenId) < self::TOKEN_MIN_LENGTH) {
155
			throw new InvalidTokenException('Token is too short for a generated token, should be the password during basic auth');
156
		}
157
158
		$tokenHash = $this->hashToken($tokenId);
159
160
		if (isset($this->cache[$tokenHash])) {
161
			if ($this->cache[$tokenHash] instanceof DoesNotExistException) {
162
				$ex = $this->cache[$tokenHash];
163
				throw new InvalidTokenException("Token does not exist: " . $ex->getMessage(), 0, $ex);
164
			}
165
			$token = $this->cache[$tokenHash];
166
		} else {
167
			try {
168
				$token = $this->mapper->getToken($tokenHash);
169
				$this->cache[$token->getToken()] = $token;
170
			} catch (DoesNotExistException $ex) {
171
				try {
172
					$token = $this->mapper->getToken($this->hashTokenWithEmptySecret($tokenId));
173
					$this->cache[$token->getToken()] = $token;
174
					$this->rotate($token, $tokenId, $tokenId);
175
				} catch (DoesNotExistException $ex2) {
176
					$this->cache[$tokenHash] = $ex2;
177
					throw new InvalidTokenException("Token does not exist: " . $ex->getMessage(), 0, $ex);
178
				}
179
			}
180
		}
181
182
		if ((int)$token->getExpires() !== 0 && $token->getExpires() < $this->time->getTime()) {
183
			throw new ExpiredTokenException($token);
184
		}
185
186
		if ($token->getType() === IToken::WIPE_TOKEN) {
187
			throw new WipeTokenException($token);
188
		}
189
190
		if ($token->getPasswordInvalid() === true) {
191
			//The password is invalid we should throw an TokenPasswordExpiredException
192
			throw new TokenPasswordExpiredException($token);
193
		}
194
195
		return $token;
196
	}
197
198
	public function getTokenById(int $tokenId): IToken {
199
		try {
200
			$token = $this->mapper->getTokenById($tokenId);
201
		} catch (DoesNotExistException $ex) {
202
			throw new InvalidTokenException("Token with ID $tokenId does not exist: " . $ex->getMessage(), 0, $ex);
203
		}
204
205
		if ((int)$token->getExpires() !== 0 && $token->getExpires() < $this->time->getTime()) {
206
			throw new ExpiredTokenException($token);
207
		}
208
209
		if ($token->getType() === IToken::WIPE_TOKEN) {
210
			throw new WipeTokenException($token);
211
		}
212
213
		if ($token->getPasswordInvalid() === true) {
214
			//The password is invalid we should throw an TokenPasswordExpiredException
215
			throw new TokenPasswordExpiredException($token);
216
		}
217
218
		return $token;
219
	}
220
221
	public function renewSessionToken(string $oldSessionId, string $sessionId): IToken {
222
		$this->cache->clear();
223
224
		return $this->atomic(function () use ($oldSessionId, $sessionId) {
225
			$token = $this->getToken($oldSessionId);
226
227
			if (!($token instanceof PublicKeyToken)) {
0 ignored issues
show
introduced by
$token is always a sub-type of OC\Authentication\Token\PublicKeyToken.
Loading history...
228
				throw new InvalidTokenException("Invalid token type");
229
			}
230
231
			$password = null;
232
			if (!is_null($token->getPassword())) {
233
				$privateKey = $this->decrypt($token->getPrivateKey(), $oldSessionId);
234
				$password = $this->decryptPassword($token->getPassword(), $privateKey);
235
			}
236
			$newToken = $this->generateToken(
237
				$sessionId,
238
				$token->getUID(),
239
				$token->getLoginName(),
240
				$password,
241
				$token->getName(),
242
				IToken::TEMPORARY_TOKEN,
243
				$token->getRemember()
244
			);
245
246
			$this->mapper->delete($token);
247
248
			return $newToken;
249
		}, $this->db);
250
	}
251
252
	public function invalidateToken(string $token) {
253
		$this->cache->clear();
254
255
		$this->mapper->invalidate($this->hashToken($token));
256
		$this->mapper->invalidate($this->hashTokenWithEmptySecret($token));
257
	}
258
259
	public function invalidateTokenById(string $uid, int $id) {
260
		$this->cache->clear();
261
262
		$this->mapper->deleteById($uid, $id);
263
	}
264
265
	public function invalidateOldTokens() {
266
		$this->cache->clear();
267
268
		$olderThan = $this->time->getTime() - (int) $this->config->getSystemValue('session_lifetime', 60 * 60 * 24);
269
		$this->logger->debug('Invalidating session tokens older than ' . date('c', $olderThan), ['app' => 'cron']);
270
		$this->mapper->invalidateOld($olderThan, IToken::DO_NOT_REMEMBER);
271
		$rememberThreshold = $this->time->getTime() - (int) $this->config->getSystemValue('remember_login_cookie_lifetime', 60 * 60 * 24 * 15);
272
		$this->logger->debug('Invalidating remembered session tokens older than ' . date('c', $rememberThreshold), ['app' => 'cron']);
273
		$this->mapper->invalidateOld($rememberThreshold, IToken::REMEMBER);
274
	}
275
276
	public function updateToken(IToken $token) {
277
		$this->cache->clear();
278
279
		if (!($token instanceof PublicKeyToken)) {
280
			throw new InvalidTokenException("Invalid token type");
281
		}
282
		$this->mapper->update($token);
283
	}
284
285
	public function updateTokenActivity(IToken $token) {
286
		$this->cache->clear();
287
288
		if (!($token instanceof PublicKeyToken)) {
289
			throw new InvalidTokenException("Invalid token type");
290
		}
291
292
		$activityInterval = $this->config->getSystemValueInt('token_auth_activity_update', 60);
293
		$activityInterval = min(max($activityInterval, 0), 300);
294
295
		/** @var PublicKeyToken $token */
296
		$now = $this->time->getTime();
297
		if ($token->getLastActivity() < ($now - $activityInterval)) {
298
			$token->setLastActivity($now);
299
			$this->mapper->updateActivity($token, $now);
300
		}
301
	}
302
303
	public function getTokenByUser(string $uid): array {
304
		return $this->mapper->getTokenByUser($uid);
305
	}
306
307
	public function getPassword(IToken $savedToken, string $tokenId): string {
308
		if (!($savedToken instanceof PublicKeyToken)) {
309
			throw new InvalidTokenException("Invalid token type");
310
		}
311
312
		if ($savedToken->getPassword() === null) {
313
			throw new PasswordlessTokenException();
314
		}
315
316
		// Decrypt private key with tokenId
317
		$privateKey = $this->decrypt($savedToken->getPrivateKey(), $tokenId);
318
319
		// Decrypt password with private key
320
		return $this->decryptPassword($savedToken->getPassword(), $privateKey);
321
	}
322
323
	public function setPassword(IToken $token, string $tokenId, string $password) {
324
		$this->cache->clear();
325
326
		if (!($token instanceof PublicKeyToken)) {
327
			throw new InvalidTokenException("Invalid token type");
328
		}
329
330
		// When changing passwords all temp tokens are deleted
331
		$this->mapper->deleteTempToken($token);
332
333
		// Update the password for all tokens
334
		$tokens = $this->mapper->getTokenByUser($token->getUID());
335
		$hashedPassword = $this->hashPassword($password);
336
		foreach ($tokens as $t) {
337
			$publicKey = $t->getPublicKey();
338
			$t->setPassword($this->encryptPassword($password, $publicKey));
339
			$t->setPasswordHash($hashedPassword);
340
			$this->updateToken($t);
341
		}
342
	}
343
344
	private function hashPassword(string $password): string {
345
		return $this->hasher->hash(sha1($password) . $password);
346
	}
347
348
	public function rotate(IToken $token, string $oldTokenId, string $newTokenId): IToken {
349
		$this->cache->clear();
350
351
		if (!($token instanceof PublicKeyToken)) {
352
			throw new InvalidTokenException("Invalid token type");
353
		}
354
355
		// Decrypt private key with oldTokenId
356
		$privateKey = $this->decrypt($token->getPrivateKey(), $oldTokenId);
357
		// Encrypt with the new token
358
		$token->setPrivateKey($this->encrypt($privateKey, $newTokenId));
359
360
		$token->setToken($this->hashToken($newTokenId));
361
		$this->updateToken($token);
362
363
		return $token;
364
	}
365
366
	private function encrypt(string $plaintext, string $token): string {
367
		$secret = $this->config->getSystemValue('secret');
368
		return $this->crypto->encrypt($plaintext, $token . $secret);
369
	}
370
371
	/**
372
	 * @throws InvalidTokenException
373
	 */
374
	private function decrypt(string $cipherText, string $token): string {
375
		$secret = $this->config->getSystemValue('secret');
376
		try {
377
			return $this->crypto->decrypt($cipherText, $token . $secret);
378
		} catch (\Exception $ex) {
379
			// Retry with empty secret as a fallback for instances where the secret might not have been set by accident
380
			try {
381
				return $this->crypto->decrypt($cipherText, $token);
382
			} catch (\Exception $ex2) {
383
				// Delete the invalid token
384
				$this->invalidateToken($token);
385
				throw new InvalidTokenException("Could not decrypt token password: " . $ex->getMessage(), 0, $ex2);
386
			}
387
		}
388
	}
389
390
	private function encryptPassword(string $password, string $publicKey): string {
391
		openssl_public_encrypt($password, $encryptedPassword, $publicKey, OPENSSL_PKCS1_OAEP_PADDING);
392
		$encryptedPassword = base64_encode($encryptedPassword);
393
394
		return $encryptedPassword;
395
	}
396
397
	private function decryptPassword(string $encryptedPassword, string $privateKey): string {
398
		$encryptedPassword = base64_decode($encryptedPassword);
399
		openssl_private_decrypt($encryptedPassword, $password, $privateKey, OPENSSL_PKCS1_OAEP_PADDING);
400
401
		return $password;
402
	}
403
404
	private function hashToken(string $token): string {
405
		$secret = $this->config->getSystemValue('secret');
406
		return hash('sha512', $token . $secret);
407
	}
408
409
	/**
410
	 * @deprecated Fallback for instances where the secret might not have been set by accident
411
	 */
412
	private function hashTokenWithEmptySecret(string $token): string {
413
		return hash('sha512', $token);
414
	}
415
416
	/**
417
	 * @throws \RuntimeException when OpenSSL reports a problem
418
	 */
419
	private function newToken(string $token,
420
							  string $uid,
421
							  string $loginName,
422
							  $password,
423
							  string $name,
424
							  int $type,
425
							  int $remember): PublicKeyToken {
426
		$dbToken = new PublicKeyToken();
427
		$dbToken->setUid($uid);
428
		$dbToken->setLoginName($loginName);
429
430
		$config = array_merge([
431
			'digest_alg' => 'sha512',
432
			'private_key_bits' => $password !== null && strlen($password) > 250 ? 4096 : 2048,
433
		], $this->config->getSystemValue('openssl', []));
434
435
		// Generate new key
436
		$res = openssl_pkey_new($config);
437
		if ($res === false) {
438
			$this->logOpensslError();
439
			throw new \RuntimeException('OpenSSL reported a problem');
440
		}
441
442
		if (openssl_pkey_export($res, $privateKey, null, $config) === false) {
443
			$this->logOpensslError();
444
			throw new \RuntimeException('OpenSSL reported a problem');
445
		}
446
447
		// Extract the public key from $res to $pubKey
448
		$publicKey = openssl_pkey_get_details($res);
449
		$publicKey = $publicKey['key'];
450
451
		$dbToken->setPublicKey($publicKey);
452
		$dbToken->setPrivateKey($this->encrypt($privateKey, $token));
453
454
		if (!is_null($password) && $this->config->getSystemValueBool('auth.storeCryptedPassword', true)) {
455
			if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) {
456
				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');
457
			}
458
			$dbToken->setPassword($this->encryptPassword($password, $publicKey));
459
			$dbToken->setPasswordHash($this->hashPassword($password));
460
		}
461
462
		$dbToken->setName($name);
463
		$dbToken->setToken($this->hashToken($token));
464
		$dbToken->setType($type);
465
		$dbToken->setRemember($remember);
466
		$dbToken->setLastActivity($this->time->getTime());
467
		$dbToken->setLastCheck($this->time->getTime());
468
		$dbToken->setVersion(PublicKeyToken::VERSION);
469
470
		return $dbToken;
471
	}
472
473
	public function markPasswordInvalid(IToken $token, string $tokenId) {
474
		$this->cache->clear();
475
476
		if (!($token instanceof PublicKeyToken)) {
477
			throw new InvalidTokenException("Invalid token type");
478
		}
479
480
		$token->setPasswordInvalid(true);
481
		$this->mapper->update($token);
482
	}
483
484
	public function updatePasswords(string $uid, string $password) {
485
		$this->cache->clear();
486
487
		// prevent setting an empty pw as result of pw-less-login
488
		if ($password === '' || !$this->config->getSystemValueBool('auth.storeCryptedPassword', true)) {
489
			return;
490
		}
491
492
		// Update the password for all tokens
493
		$tokens = $this->mapper->getTokenByUser($uid);
494
		$newPasswordHash = null;
495
496
		/**
497
		 * - true: The password hash could not be verified anymore
498
		 *     and the token needs to be updated with the newly encrypted password
499
		 * - false: The hash could still be verified
500
		 * - missing: The hash needs to be verified
501
		 */
502
		$hashNeedsUpdate = [];
503
504
		foreach ($tokens as $t) {
505
			if (!isset($hashNeedsUpdate[$t->getPasswordHash()])) {
506
				if ($t->getPasswordHash() === null) {
507
					$hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true;
508
				} elseif (!$this->hasher->verify(sha1($password) . $password, $t->getPasswordHash())) {
509
					$hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true;
510
				} else {
511
					$hashNeedsUpdate[$t->getPasswordHash() ?: ''] = false;
512
				}
513
			}
514
			$needsUpdating = $hashNeedsUpdate[$t->getPasswordHash() ?: ''] ?? true;
515
516
			if ($needsUpdating) {
517
				if ($newPasswordHash === null) {
518
					$newPasswordHash = $this->hashPassword($password);
519
				}
520
521
				$publicKey = $t->getPublicKey();
522
				$t->setPassword($this->encryptPassword($password, $publicKey));
523
				$t->setPasswordHash($newPasswordHash);
524
				$t->setPasswordInvalid(false);
525
				$this->updateToken($t);
526
			}
527
		}
528
529
		// If password hashes are different we update them all to be equal so
530
		// that the next execution only needs to verify once
531
		if (count($hashNeedsUpdate) > 1) {
532
			$newPasswordHash = $this->hashPassword($password);
533
			$this->mapper->updateHashesForUser($uid, $newPasswordHash);
534
		}
535
	}
536
537
	private function logOpensslError() {
538
		$errors = [];
539
		while ($error = openssl_error_string()) {
540
			$errors[] = $error;
541
		}
542
		$this->logger->critical('Something is wrong with your openssl setup: ' . implode(', ', $errors));
543
	}
544
}
545