Files
nextcloud-server-mirror/lib/private/Authentication/Token/PublicKeyTokenProvider.php
Git'Fellow 4920bd1725 refactor: add PHP 8.3 types to BC-safe class constants
PHP 8.3 allows class constants to declare a type. Adopt it where doing so
cannot break third-party apps.

Typing an inheritable constant is a hard BC break: a subclass that
redeclares it untyped fails to load with "Type of C::FOO must be
compatible with P::FOO of type string". Changes are therefore limited to
constants that cannot be redeclared by a subclass:

  - private const (not inherited)
  - final public/protected const
  - constants declared in a final class or an enum

Interface constants, trait constants, and public/protected constants in
non-final (including abstract) classes are left untyped, as is all of
lib/public (OCP) and lib/unstable (NCU).

One review-requested exception: the public OBJECT_PREFIX/OBJECT_SUFFIX
constants of the app-internal CalDAV import helpers (TextImporter,
XmlImporter) are typed as well; these classes are not public API and
have no subclasses.

Only string, int and array are used. float is avoided because it would
silently coerce an int literal and change === comparisons. No constant
name or value is modified: every changed line adds only the type token,
so runtime behaviour is unchanged.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
2026-08-11 13:52:52 +01:00

583 lines
19 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Authentication\Token;
use OC\Authentication\Exceptions\ExpiredTokenException;
use OC\Authentication\Exceptions\InvalidTokenException;
use OC\Authentication\Exceptions\PasswordlessTokenException;
use OC\Authentication\Exceptions\TokenPasswordExpiredException;
use OC\Authentication\Exceptions\WipeTokenException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Authentication\Events\TokenInvalidatedEvent;
use OCP\Authentication\Token\IToken as OCPIToken;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\Security\ICrypto;
use OCP\Security\IHasher;
use Psr\Log\LoggerInterface;
class PublicKeyTokenProvider implements IProvider {
public const TOKEN_MIN_LENGTH = 22;
/** Token cache TTL in seconds */
private const int TOKEN_CACHE_TTL = 10;
use TTransactional;
/** @var ICache */
private $cache;
public function __construct(
private PublicKeyTokenMapper $mapper,
private ICrypto $crypto,
private IConfig $config,
private IDBConnection $db,
private LoggerInterface $logger,
private ITimeFactory $time,
private IHasher $hasher,
ICacheFactory $cacheFactory,
private IEventDispatcher $eventDispatcher,
) {
$this->cache = $cacheFactory->isLocalCacheAvailable()
? $cacheFactory->createLocal('authtoken_')
: $cacheFactory->createInMemory();
}
/**
* {@inheritDoc}
*/
#[\Override]
public function generateToken(string $token,
string $uid,
string $loginName,
?string $password,
string $name,
int $type = OCPIToken::TEMPORARY_TOKEN,
int $remember = OCPIToken::DO_NOT_REMEMBER,
?array $scope = null,
?int $expires = null,
): OCPIToken {
if (strlen($token) < self::TOKEN_MIN_LENGTH) {
$exception = new InvalidTokenException('Token is too short, minimum of ' . self::TOKEN_MIN_LENGTH . ' characters is required, ' . strlen($token) . ' characters given');
$this->logger->error('Invalid token provided when generating new token', ['exception' => $exception]);
throw $exception;
}
if (mb_strlen($name) > 128) {
$name = mb_substr($name, 0, 120) . '…';
}
// We need to check against one old token to see if there is a password
// hash that we can reuse for detecting outdated passwords
$randomOldToken = $this->mapper->getFirstTokenForUser($uid);
$oldTokenMatches = $randomOldToken && $randomOldToken->getPasswordHash() && $password !== null && $this->hasher->verify(sha1($password) . $password, $randomOldToken->getPasswordHash());
$dbToken = $this->newToken($token, $uid, $loginName, $password, $name, $type, $remember, $expires);
if ($oldTokenMatches) {
$dbToken->setPasswordHash($randomOldToken->getPasswordHash());
}
if ($scope !== null) {
$dbToken->setScope($scope);
}
$this->mapper->insert($dbToken);
if (!$oldTokenMatches && $password !== null) {
$this->updatePasswords($uid, $password);
}
// Add the token to the cache
$this->cacheToken($dbToken);
return $dbToken;
}
#[\Override]
public function getToken(string $tokenId): OCPIToken {
/**
* Token length: 72
* @see \OC\Core\Controller\ClientFlowLoginController::generateAppPassword
* @see \OC\Core\Controller\AppPasswordController::getAppPassword
* @see \OC\Core\Command\User\AddAppPassword::execute
* @see \OC\Core\Service\LoginFlowV2Service::flowDone
* @see \OCA\Talk\MatterbridgeManager::generatePassword
* @see \OCA\Preferred_Providers\Controller\PasswordController::generateAppPassword
* @see \OCA\GlobalSiteSelector\TokenHandler::generateAppPassword
*
* Token length: 22-256 - https://www.php.net/manual/en/session.configuration.php#ini.session.sid-length
* @see \OC\User\Session::createSessionToken
*
* Token length: 29
* @see \OCA\Settings\Controller\AuthSettingsController::generateRandomDeviceToken
* @see \OCA\Registration\Service\RegistrationService::generateAppPassword
*/
if (strlen($tokenId) < self::TOKEN_MIN_LENGTH) {
throw new InvalidTokenException('Token is too short for a generated token, should be the password during basic auth');
}
$tokenHash = $this->hashToken($tokenId);
if ($token = $this->getTokenFromCache($tokenHash)) {
$this->checkToken($token);
return $token;
}
try {
$token = $this->mapper->getToken($tokenHash);
$this->cacheToken($token);
} catch (DoesNotExistException $ex) {
try {
$token = $this->mapper->getToken($this->hashTokenWithEmptySecret($tokenId));
$this->rotate($token, $tokenId, $tokenId);
} catch (DoesNotExistException) {
$this->cacheInvalidHash($tokenHash);
throw new InvalidTokenException('Token does not exist: ' . $ex->getMessage(), 0, $ex);
}
}
$this->checkToken($token);
return $token;
}
/**
* @throws InvalidTokenException when token doesn't exist
*/
private function getTokenFromCache(string $tokenHash): ?PublicKeyToken {
$serializedToken = $this->cache->get($tokenHash);
if ($serializedToken === false) {
return null;
}
if ($serializedToken === null) {
return null;
}
$token = unserialize($serializedToken, [
'allowed_classes' => [PublicKeyToken::class],
]);
return $token instanceof PublicKeyToken ? $token : null;
}
private function cacheToken(PublicKeyToken $token): void {
$this->cache->set($token->getToken(), serialize($token), self::TOKEN_CACHE_TTL);
}
private function cacheInvalidHash(string $tokenHash): void {
// Invalid entries can be kept longer in cache since it’s unlikely to reuse them
$this->cache->set($tokenHash, false, self::TOKEN_CACHE_TTL * 2);
}
#[\Override]
public function getTokenById(int $tokenId): OCPIToken {
try {
$token = $this->mapper->getTokenById($tokenId);
} catch (DoesNotExistException $ex) {
throw new InvalidTokenException("Token with ID $tokenId does not exist: " . $ex->getMessage(), 0, $ex);
}
$this->checkToken($token);
return $token;
}
private function checkToken($token): void {
if ((int)$token->getExpires() !== 0 && $token->getExpires() < $this->time->getTime()) {
throw new ExpiredTokenException($token);
}
if ($token->getType() === OCPIToken::WIPE_TOKEN) {
throw new WipeTokenException($token);
}
if ($token->getPasswordInvalid() === true) {
//The password is invalid we should throw an TokenPasswordExpiredException
throw new TokenPasswordExpiredException($token);
}
}
#[\Override]
public function renewSessionToken(string $oldSessionId, string $sessionId): OCPIToken {
return $this->atomic(function () use ($oldSessionId, $sessionId) {
$token = $this->getToken($oldSessionId);
if (!($token instanceof PublicKeyToken)) {
throw new InvalidTokenException('Invalid token type');
}
$password = null;
if (!is_null($token->getPassword())) {
$privateKey = $this->decrypt($token->getPrivateKey(), $oldSessionId);
$password = $this->decryptPassword($token->getPassword(), $privateKey);
}
$scope = $token->getScope() === '' ? null : $token->getScopeAsArray();
$newToken = $this->generateToken(
$sessionId,
$token->getUID(),
$token->getLoginName(),
$password,
$token->getName(),
OCPIToken::TEMPORARY_TOKEN,
$token->getRemember(),
$scope,
$token->getExpires(),
);
$this->cacheToken($newToken);
$this->cacheInvalidHash($token->getToken());
$this->mapper->delete($token);
return $newToken;
}, $this->db);
}
#[\Override]
public function invalidateToken(string $token) {
$tokenHash = $this->hashToken($token);
$tokenEntry = null;
try {
$tokenEntry = $this->mapper->getToken($tokenHash);
} catch (DoesNotExistException) {
}
$this->mapper->invalidate($this->hashToken($token));
$this->mapper->invalidate($this->hashTokenWithEmptySecret($token));
$this->cacheInvalidHash($tokenHash);
if ($tokenEntry !== null) {
$this->eventDispatcher->dispatchTyped(new TokenInvalidatedEvent($tokenEntry));
}
}
#[\Override]
public function invalidateTokenById(string $uid, int $id) {
$token = $this->mapper->getTokenById($id);
if ($token->getUID() !== $uid) {
return;
}
$this->mapper->invalidate($token->getToken());
$this->cacheInvalidHash($token->getToken());
$this->eventDispatcher->dispatchTyped(new TokenInvalidatedEvent($token));
}
#[\Override]
public function invalidateOldTokens() {
$olderThan = $this->time->getTime() - $this->config->getSystemValueInt('session_lifetime', 60 * 60 * 24);
$this->logger->debug('Invalidating session tokens older than ' . date('c', $olderThan), ['app' => 'cron']);
$this->mapper->invalidateOld($olderThan, OCPIToken::TEMPORARY_TOKEN, OCPIToken::DO_NOT_REMEMBER);
$rememberThreshold = $this->time->getTime() - $this->config->getSystemValueInt('remember_login_cookie_lifetime', 60 * 60 * 24 * 15);
$this->logger->debug('Invalidating remembered session tokens older than ' . date('c', $rememberThreshold), ['app' => 'cron']);
$this->mapper->invalidateOld($rememberThreshold, OCPIToken::TEMPORARY_TOKEN, OCPIToken::REMEMBER);
$wipeThreshold = $this->time->getTime() - $this->config->getSystemValueInt('token_auth_wipe_token_retention', 60 * 60 * 24 * 60);
$this->logger->debug('Invalidating auth tokens marked for remote wipe older than ' . date('c', $wipeThreshold), ['app' => 'cron']);
$this->mapper->invalidateOld($wipeThreshold, OCPIToken::WIPE_TOKEN);
$authTokenThreshold = $this->time->getTime() - $this->config->getSystemValueInt('token_auth_token_retention', 60 * 60 * 24 * 365);
$this->logger->debug('Invalidating auth tokens older than ' . date('c', $authTokenThreshold), ['app' => 'cron']);
$this->mapper->invalidateOld($authTokenThreshold, OCPIToken::PERMANENT_TOKEN);
}
#[\Override]
public function invalidateLastUsedBefore(string $uid, int $before): void {
$this->mapper->invalidateLastUsedBefore($uid, $before);
}
#[\Override]
public function updateToken(OCPIToken $token) {
if (!($token instanceof PublicKeyToken)) {
throw new InvalidTokenException('Invalid token type');
}
$this->mapper->update($token);
$this->cacheToken($token);
}
#[\Override]
public function updateTokenActivity(OCPIToken $token) {
if (!($token instanceof PublicKeyToken)) {
throw new InvalidTokenException('Invalid token type');
}
$activityInterval = $this->config->getSystemValueInt('token_auth_activity_update', 60);
$activityInterval = min(max($activityInterval, 0), 300);
/** @var PublicKeyToken $token */
$now = $this->time->getTime();
if ($token->getLastActivity() < ($now - $activityInterval)) {
$token->setLastActivity($now);
$this->mapper->updateActivity($token, $now);
$this->cacheToken($token);
}
}
#[\Override]
public function getTokenByUser(string $uid): array {
return $this->mapper->getTokenByUser($uid);
}
#[\Override]
public function getPassword(OCPIToken $savedToken, string $tokenId): string {
if (!($savedToken instanceof PublicKeyToken)) {
throw new InvalidTokenException('Invalid token type');
}
if ($savedToken->getPassword() === null) {
throw new PasswordlessTokenException();
}
// Decrypt private key with tokenId
$privateKey = $this->decrypt($savedToken->getPrivateKey(), $tokenId);
// Decrypt password with private key
return $this->decryptPassword($savedToken->getPassword(), $privateKey);
}
#[\Override]
public function setPassword(OCPIToken $token, string $tokenId, string $password) {
if (!($token instanceof PublicKeyToken)) {
throw new InvalidTokenException('Invalid token type');
}
$this->atomic(function () use ($password, $token): void {
// When changing passwords all temp tokens are deleted
$this->mapper->deleteTempToken($token);
// Update the password for all tokens
$tokens = $this->mapper->getTokenByUser($token->getUID());
$hashedPassword = $this->hashPassword($password);
foreach ($tokens as $t) {
$publicKey = $t->getPublicKey();
$t->setPassword($this->encryptPassword($password, $publicKey));
$t->setPasswordHash($hashedPassword);
$this->updateToken($t);
}
}, $this->db);
}
private function hashPassword(string $password): string {
return $this->hasher->hash(sha1($password) . $password);
}
#[\Override]
public function rotate(OCPIToken $token, string $oldTokenId, string $newTokenId): OCPIToken {
if (!($token instanceof PublicKeyToken)) {
throw new InvalidTokenException('Invalid token type');
}
// Decrypt private key with oldTokenId
$privateKey = $this->decrypt($token->getPrivateKey(), $oldTokenId);
// Encrypt with the new token
$token->setPrivateKey($this->encrypt($privateKey, $newTokenId));
$token->setToken($this->hashToken($newTokenId));
$this->updateToken($token);
return $token;
}
private function encrypt(string $plaintext, string $token): string {
$secret = $this->config->getSystemValueString('secret');
return $this->crypto->encrypt($plaintext, $token . $secret);
}
/**
* @throws InvalidTokenException
*/
private function decrypt(string $cipherText, string $token): string {
$secret = $this->config->getSystemValueString('secret');
try {
return $this->crypto->decrypt($cipherText, $token . $secret);
} catch (\Exception $ex) {
// Retry with empty secret as a fallback for instances where the secret might not have been set by accident
try {
return $this->crypto->decrypt($cipherText, $token);
} catch (\Exception $ex2) {
// Delete the invalid token
$this->invalidateToken($token);
throw new InvalidTokenException('Could not decrypt token password: ' . $ex->getMessage(), 0, $ex2);
}
}
}
private function encryptPassword(string $password, string $publicKey): string {
openssl_public_encrypt($password, $encryptedPassword, $publicKey, OPENSSL_PKCS1_OAEP_PADDING);
$encryptedPassword = base64_encode($encryptedPassword);
return $encryptedPassword;
}
private function decryptPassword(string $encryptedPassword, string $privateKey): string {
$encryptedPassword = base64_decode($encryptedPassword);
openssl_private_decrypt($encryptedPassword, $password, $privateKey, OPENSSL_PKCS1_OAEP_PADDING);
return $password;
}
private function hashToken(string $token): string {
$secret = $this->config->getSystemValueString('secret');
return hash('sha512', $token . $secret);
}
/**
* @deprecated 26.0.0 Fallback for instances where the secret might not have been set by accident
*/
private function hashTokenWithEmptySecret(string $token): string {
return hash('sha512', $token);
}
/**
* @throws \RuntimeException when OpenSSL reports a problem
*/
private function newToken(string $token,
string $uid,
string $loginName,
$password,
string $name,
int $type,
int $remember,
?int $expires,
): PublicKeyToken {
$dbToken = new PublicKeyToken();
$dbToken->setUid($uid);
$dbToken->setLoginName($loginName);
$config = array_merge([
'digest_alg' => 'sha512',
'private_key_bits' => $password !== null && strlen($password) > 250 ? 4096 : 2048,
], $this->config->getSystemValue('openssl', []));
// Generate new key
$res = openssl_pkey_new($config);
if ($res === false) {
$this->logOpensslError();
throw new \RuntimeException('OpenSSL reported a problem');
}
if (openssl_pkey_export($res, $privateKey, null, $config) === false) {
$this->logOpensslError();
throw new \RuntimeException('OpenSSL reported a problem');
}
// Extract the public key from $res to $pubKey
$publicKey = openssl_pkey_get_details($res);
$publicKey = $publicKey['key'];
$dbToken->setPublicKey($publicKey);
$dbToken->setPrivateKey($this->encrypt($privateKey, $token));
if (!is_null($password) && $this->config->getSystemValueBool('auth.storeCryptedPassword', true)) {
if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) {
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');
}
$dbToken->setPassword($this->encryptPassword($password, $publicKey));
$dbToken->setPasswordHash($this->hashPassword($password));
}
$dbToken->setName($name);
$dbToken->setToken($this->hashToken($token));
$dbToken->setType($type);
$dbToken->setRemember($remember);
$dbToken->setLastActivity($this->time->getTime());
$dbToken->setLastCheck($this->time->getTime());
$dbToken->setVersion(PublicKeyToken::VERSION);
if ($expires !== null) {
$dbToken->setExpires($expires);
} elseif ($type === OCPIToken::ONETIME_TOKEN) {
// Minimum duration is 2 minutes as shown in the UI
$expirationDuration = max(120, $this->config->getSystemValueInt('auth_onetime_token_validity', 120));
$dbToken->setExpires($this->time->getTime() + $expirationDuration);
}
return $dbToken;
}
#[\Override]
public function markPasswordInvalid(OCPIToken $token, string $tokenId) {
if (!($token instanceof PublicKeyToken)) {
throw new InvalidTokenException('Invalid token type');
}
$token->setPasswordInvalid(true);
$this->mapper->update($token);
$this->cacheToken($token);
}
#[\Override]
public function updatePasswords(string $uid, string $password) {
// prevent setting an empty pw as result of pw-less-login
if ($password === '' || !$this->config->getSystemValueBool('auth.storeCryptedPassword', true)) {
return;
}
$this->atomic(function () use ($password, $uid): void {
// Update the password for all tokens
$tokens = $this->mapper->getTokenByUser($uid);
$newPasswordHash = null;
/**
* - true: The password hash could not be verified anymore
* and the token needs to be updated with the newly encrypted password
* - false: The hash could still be verified
* - missing: The hash needs to be verified
*/
$hashNeedsUpdate = [];
foreach ($tokens as $t) {
$passwordHash = $t->getPasswordHash();
if ($passwordHash === null) {
$hashNeedsUpdate[''] = true;
$needsUpdating = true;
} elseif (!isset($hashNeedsUpdate[$passwordHash])) {
if (!$this->hasher->verify(sha1($password) . $password, $passwordHash)) {
$hashNeedsUpdate[$passwordHash] = true;
} else {
$hashNeedsUpdate[$passwordHash] = false;
}
$needsUpdating = $hashNeedsUpdate[$passwordHash] ?? true;
}
if ($needsUpdating) {
if ($newPasswordHash === null) {
$newPasswordHash = $this->hashPassword($password);
}
$publicKey = $t->getPublicKey();
$t->setPassword($this->encryptPassword($password, $publicKey));
$t->setPasswordHash($newPasswordHash);
$t->setPasswordInvalid(false);
$this->updateToken($t);
}
}
// If password hashes are different we update them all to be equal so
// that the next execution only needs to verify once
if (count($hashNeedsUpdate) > 1) {
$newPasswordHash = $this->hashPassword($password);
$this->mapper->updateHashesForUser($uid, $newPasswordHash);
}
}, $this->db);
}
private function logOpensslError() {
$errors = [];
while ($error = openssl_error_string()) {
$errors[] = $error;
}
$this->logger->critical('Something is wrong with your openssl setup: ' . implode(', ', $errors));
}
}