diff options
Diffstat (limited to 'lib/private/Authentication/Token')
-rw-r--r-- | lib/private/Authentication/Token/INamedToken.php | 19 | ||||
-rw-r--r-- | lib/private/Authentication/Token/IProvider.php | 172 | ||||
-rw-r--r-- | lib/private/Authentication/Token/IToken.php | 17 | ||||
-rw-r--r-- | lib/private/Authentication/Token/IWipeableToken.php | 16 | ||||
-rw-r--r-- | lib/private/Authentication/Token/Manager.php | 243 | ||||
-rw-r--r-- | lib/private/Authentication/Token/PublicKeyToken.php | 219 | ||||
-rw-r--r-- | lib/private/Authentication/Token/PublicKeyTokenMapper.php | 252 | ||||
-rw-r--r-- | lib/private/Authentication/Token/PublicKeyTokenProvider.php | 566 | ||||
-rw-r--r-- | lib/private/Authentication/Token/RemoteWipe.php | 134 | ||||
-rw-r--r-- | lib/private/Authentication/Token/TokenCleanupJob.php | 26 |
10 files changed, 1664 insertions, 0 deletions
diff --git a/lib/private/Authentication/Token/INamedToken.php b/lib/private/Authentication/Token/INamedToken.php new file mode 100644 index 00000000000..9a90cfc7d76 --- /dev/null +++ b/lib/private/Authentication/Token/INamedToken.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +interface INamedToken extends IToken { + /** + * Set token name + * + * @param string $name + * @return void + */ + public function setName(string $name): void; +} diff --git a/lib/private/Authentication/Token/IProvider.php b/lib/private/Authentication/Token/IProvider.php new file mode 100644 index 00000000000..d47427e79bf --- /dev/null +++ b/lib/private/Authentication/Token/IProvider.php @@ -0,0 +1,172 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Token; + +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken as OCPIToken; + +interface IProvider { + /** + * Create and persist a new token + * + * @param string $token + * @param string $uid + * @param string $loginName + * @param string|null $password + * @param string $name Name will be trimmed to 120 chars when longer + * @param int $type token type + * @param int $remember whether the session token should be used for remember-me + * @return OCPIToken + * @throws \RuntimeException when OpenSSL reports a problem + */ + 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, + ): OCPIToken; + + /** + * Get a token by token id + * + * @param string $tokenId + * @throws InvalidTokenException + * @throws ExpiredTokenException + * @throws WipeTokenException + * @return OCPIToken + */ + public function getToken(string $tokenId): OCPIToken; + + /** + * Get a token by token id + * + * @param int $tokenId + * @throws InvalidTokenException + * @throws ExpiredTokenException + * @throws WipeTokenException + * @return OCPIToken + */ + public function getTokenById(int $tokenId): OCPIToken; + + /** + * Duplicate an existing session token + * + * @param string $oldSessionId + * @param string $sessionId + * @throws InvalidTokenException + * @throws \RuntimeException when OpenSSL reports a problem + * @return OCPIToken The new token + */ + public function renewSessionToken(string $oldSessionId, string $sessionId): OCPIToken; + + /** + * Invalidate (delete) the given session token + * + * @param string $token + */ + public function invalidateToken(string $token); + + /** + * Invalidate (delete) the given token + * + * @param string $uid + * @param int $id + */ + public function invalidateTokenById(string $uid, int $id); + + /** + * Invalidate (delete) old session tokens + */ + public function invalidateOldTokens(); + + /** + * Invalidate (delete) tokens last used before a given date + */ + public function invalidateLastUsedBefore(string $uid, int $before): void; + + /** + * Save the updated token + * + * @param OCPIToken $token + */ + public function updateToken(OCPIToken $token); + + /** + * Update token activity timestamp + * + * @param OCPIToken $token + */ + public function updateTokenActivity(OCPIToken $token); + + /** + * Get all tokens of a user + * + * The provider may limit the number of result rows in case of an abuse + * where a high number of (session) tokens is generated + * + * @param string $uid + * @return OCPIToken[] + */ + public function getTokenByUser(string $uid): array; + + /** + * Get the (unencrypted) password of the given token + * + * @param OCPIToken $savedToken + * @param string $tokenId + * @throws InvalidTokenException + * @throws PasswordlessTokenException + * @return string + */ + public function getPassword(OCPIToken $savedToken, string $tokenId): string; + + /** + * Encrypt and set the password of the given token + * + * @param OCPIToken $token + * @param string $tokenId + * @param string $password + * @throws InvalidTokenException + */ + public function setPassword(OCPIToken $token, string $tokenId, string $password); + + /** + * Rotate the token. Useful for for example oauth tokens + * + * @param OCPIToken $token + * @param string $oldTokenId + * @param string $newTokenId + * @return OCPIToken + * @throws \RuntimeException when OpenSSL reports a problem + */ + public function rotate(OCPIToken $token, string $oldTokenId, string $newTokenId): OCPIToken; + + /** + * Marks a token as having an invalid password. + * + * @param OCPIToken $token + * @param string $tokenId + */ + public function markPasswordInvalid(OCPIToken $token, string $tokenId); + + /** + * Update all the passwords of $uid if required + * + * @param string $uid + * @param string $password + */ + public function updatePasswords(string $uid, string $password); +} diff --git a/lib/private/Authentication/Token/IToken.php b/lib/private/Authentication/Token/IToken.php new file mode 100644 index 00000000000..2028a0b328c --- /dev/null +++ b/lib/private/Authentication/Token/IToken.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Token; + +use OCP\Authentication\Token\IToken as OCPIToken; + +/** + * @deprecated 28.0.0 use {@see \OCP\Authentication\Token\IToken} instead + */ +interface IToken extends OCPIToken { +} diff --git a/lib/private/Authentication/Token/IWipeableToken.php b/lib/private/Authentication/Token/IWipeableToken.php new file mode 100644 index 00000000000..fc1476785cd --- /dev/null +++ b/lib/private/Authentication/Token/IWipeableToken.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +interface IWipeableToken extends IToken { + /** + * Mark the token for remote wipe + */ + public function wipe(): void; +} diff --git a/lib/private/Authentication/Token/Manager.php b/lib/private/Authentication/Token/Manager.php new file mode 100644 index 00000000000..6953f47b004 --- /dev/null +++ b/lib/private/Authentication/Token/Manager.php @@ -0,0 +1,243 @@ +<?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 Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OC\Authentication\Exceptions\InvalidTokenException as OcInvalidTokenException; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IProvider as OCPIProvider; +use OCP\Authentication\Token\IToken as OCPIToken; + +class Manager implements IProvider, OCPIProvider { + /** @var PublicKeyTokenProvider */ + private $publicKeyTokenProvider; + + public function __construct(PublicKeyTokenProvider $publicKeyTokenProvider) { + $this->publicKeyTokenProvider = $publicKeyTokenProvider; + } + + /** + * Create and persist a new token + * + * @param string $token + * @param string $uid + * @param string $loginName + * @param string|null $password + * @param string $name Name will be trimmed to 120 chars when longer + * @param int $type token type + * @param int $remember whether the session token should be used for remember-me + * @return OCPIToken + */ + public function generateToken(string $token, + string $uid, + string $loginName, + $password, + string $name, + int $type = OCPIToken::TEMPORARY_TOKEN, + int $remember = OCPIToken::DO_NOT_REMEMBER, + ?array $scope = null, + ): OCPIToken { + if (mb_strlen($name) > 128) { + $name = mb_substr($name, 0, 120) . '…'; + } + + try { + return $this->publicKeyTokenProvider->generateToken( + $token, + $uid, + $loginName, + $password, + $name, + $type, + $remember, + $scope, + ); + } catch (UniqueConstraintViolationException $e) { + // It's rare, but if two requests of the same session (e.g. env-based SAML) + // try to create the session token they might end up here at the same time + // because we use the session ID as token and the db token is created anew + // with every request. + // + // If the UIDs match, then this should be fine. + $existing = $this->getToken($token); + if ($existing->getUID() !== $uid) { + throw new \Exception('Token conflict handled, but UIDs do not match. This should not happen', 0, $e); + } + return $existing; + } + } + + /** + * Save the updated token + * + * @param OCPIToken $token + * @throws InvalidTokenException + */ + public function updateToken(OCPIToken $token) { + $provider = $this->getProvider($token); + $provider->updateToken($token); + } + + /** + * Update token activity timestamp + * + * @throws InvalidTokenException + * @param OCPIToken $token + */ + public function updateTokenActivity(OCPIToken $token) { + $provider = $this->getProvider($token); + $provider->updateTokenActivity($token); + } + + /** + * @param string $uid + * @return OCPIToken[] + */ + public function getTokenByUser(string $uid): array { + return $this->publicKeyTokenProvider->getTokenByUser($uid); + } + + /** + * Get a token by token + * + * @param string $tokenId + * @throws InvalidTokenException + * @throws \RuntimeException when OpenSSL reports a problem + * @return OCPIToken + */ + public function getToken(string $tokenId): OCPIToken { + try { + return $this->publicKeyTokenProvider->getToken($tokenId); + } catch (WipeTokenException $e) { + throw $e; + } catch (ExpiredTokenException $e) { + throw $e; + } catch (InvalidTokenException $e) { + throw $e; + } + } + + /** + * Get a token by token id + * + * @param int $tokenId + * @throws InvalidTokenException + * @return OCPIToken + */ + public function getTokenById(int $tokenId): OCPIToken { + try { + return $this->publicKeyTokenProvider->getTokenById($tokenId); + } catch (ExpiredTokenException $e) { + throw $e; + } catch (WipeTokenException $e) { + throw $e; + } catch (InvalidTokenException $e) { + throw $e; + } + } + + /** + * @param string $oldSessionId + * @param string $sessionId + * @throws InvalidTokenException + * @return OCPIToken + */ + public function renewSessionToken(string $oldSessionId, string $sessionId): OCPIToken { + try { + return $this->publicKeyTokenProvider->renewSessionToken($oldSessionId, $sessionId); + } catch (ExpiredTokenException $e) { + throw $e; + } catch (InvalidTokenException $e) { + throw $e; + } + } + + /** + * @param OCPIToken $savedToken + * @param string $tokenId session token + * @throws InvalidTokenException + * @throws PasswordlessTokenException + * @return string + */ + public function getPassword(OCPIToken $savedToken, string $tokenId): string { + $provider = $this->getProvider($savedToken); + return $provider->getPassword($savedToken, $tokenId); + } + + public function setPassword(OCPIToken $token, string $tokenId, string $password) { + $provider = $this->getProvider($token); + $provider->setPassword($token, $tokenId, $password); + } + + public function invalidateToken(string $token) { + $this->publicKeyTokenProvider->invalidateToken($token); + } + + public function invalidateTokenById(string $uid, int $id) { + $this->publicKeyTokenProvider->invalidateTokenById($uid, $id); + } + + public function invalidateOldTokens() { + $this->publicKeyTokenProvider->invalidateOldTokens(); + } + + public function invalidateLastUsedBefore(string $uid, int $before): void { + $this->publicKeyTokenProvider->invalidateLastUsedBefore($uid, $before); + } + + /** + * @param OCPIToken $token + * @param string $oldTokenId + * @param string $newTokenId + * @return OCPIToken + * @throws InvalidTokenException + * @throws \RuntimeException when OpenSSL reports a problem + */ + public function rotate(OCPIToken $token, string $oldTokenId, string $newTokenId): OCPIToken { + if ($token instanceof PublicKeyToken) { + return $this->publicKeyTokenProvider->rotate($token, $oldTokenId, $newTokenId); + } + + /** @psalm-suppress DeprecatedClass We have to throw the OC version so both OC and OCP catches catch it */ + throw new OcInvalidTokenException(); + } + + /** + * @param OCPIToken $token + * @return IProvider + * @throws InvalidTokenException + */ + private function getProvider(OCPIToken $token): IProvider { + if ($token instanceof PublicKeyToken) { + return $this->publicKeyTokenProvider; + } + /** @psalm-suppress DeprecatedClass We have to throw the OC version so both OC and OCP catches catch it */ + throw new OcInvalidTokenException(); + } + + + public function markPasswordInvalid(OCPIToken $token, string $tokenId) { + $this->getProvider($token)->markPasswordInvalid($token, $tokenId); + } + + public function updatePasswords(string $uid, string $password) { + $this->publicKeyTokenProvider->updatePasswords($uid, $password); + } + + public function invalidateTokensOfUser(string $uid, ?string $clientName) { + $tokens = $this->getTokenByUser($uid); + foreach ($tokens as $token) { + if ($clientName === null || ($token->getName() === $clientName)) { + $this->invalidateTokenById($uid, $token->getId()); + } + } + } +} diff --git a/lib/private/Authentication/Token/PublicKeyToken.php b/lib/private/Authentication/Token/PublicKeyToken.php new file mode 100644 index 00000000000..be427ab4839 --- /dev/null +++ b/lib/private/Authentication/Token/PublicKeyToken.php @@ -0,0 +1,219 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +use OCP\AppFramework\Db\Entity; +use OCP\Authentication\Token\IToken; +use OCP\DB\Types; + +/** + * @method void setId(int $id) + * @method void setUid(string $uid); + * @method void setLoginName(string $loginname) + * @method string getToken() + * @method void setType(int $type) + * @method int getType() + * @method void setRemember(int $remember) + * @method void setLastActivity(int $lastactivity) + * @method int getLastActivity() + * @method string getPrivateKey() + * @method void setPrivateKey(string $key) + * @method string getPublicKey() + * @method void setPublicKey(string $key) + * @method void setVersion(int $version) + * @method bool getPasswordInvalid() + * @method string getPasswordHash() + * @method setPasswordHash(string $hash) + */ +class PublicKeyToken extends Entity implements INamedToken, IWipeableToken { + public const VERSION = 2; + + /** @var string user UID */ + protected $uid; + + /** @var string login name used for generating the token */ + protected $loginName; + + /** @var string encrypted user password */ + protected $password; + + /** @var string hashed user password */ + protected $passwordHash; + + /** @var string token name (e.g. browser/OS) */ + protected $name; + + /** @var string */ + protected $token; + + /** @var int */ + protected $type; + + /** @var int */ + protected $remember; + + /** @var int */ + protected $lastActivity; + + /** @var int */ + protected $lastCheck; + + /** @var string */ + protected $scope; + + /** @var int */ + protected $expires; + + /** @var string */ + protected $privateKey; + + /** @var string */ + protected $publicKey; + + /** @var int */ + protected $version; + + /** @var bool */ + protected $passwordInvalid; + + public function __construct() { + $this->addType('uid', 'string'); + $this->addType('loginName', 'string'); + $this->addType('password', 'string'); + $this->addType('passwordHash', 'string'); + $this->addType('name', 'string'); + $this->addType('token', 'string'); + $this->addType('type', Types::INTEGER); + $this->addType('remember', Types::INTEGER); + $this->addType('lastActivity', Types::INTEGER); + $this->addType('lastCheck', Types::INTEGER); + $this->addType('scope', 'string'); + $this->addType('expires', Types::INTEGER); + $this->addType('publicKey', 'string'); + $this->addType('privateKey', 'string'); + $this->addType('version', Types::INTEGER); + $this->addType('passwordInvalid', Types::BOOLEAN); + } + + public function getId(): int { + return $this->id; + } + + public function getUID(): string { + return $this->uid; + } + + /** + * Get the login name used when generating the token + * + * @return string + */ + public function getLoginName(): string { + return parent::getLoginName(); + } + + /** + * Get the (encrypted) login password + */ + public function getPassword(): ?string { + return parent::getPassword(); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'lastActivity' => $this->lastActivity, + 'type' => $this->type, + 'scope' => $this->getScopeAsArray() + ]; + } + + /** + * Get the timestamp of the last password check + * + * @return int + */ + public function getLastCheck(): int { + return parent::getLastCheck(); + } + + /** + * Get the timestamp of the last password check + */ + public function setLastCheck(int $time): void { + parent::setLastCheck($time); + } + + public function getScope(): string { + $scope = parent::getScope(); + if ($scope === null) { + return ''; + } + + return $scope; + } + + public function getScopeAsArray(): array { + $scope = json_decode($this->getScope(), true); + if (!$scope) { + return [ + IToken::SCOPE_FILESYSTEM => true + ]; + } + return $scope; + } + + public function setScope(array|string|null $scope): void { + if (is_array($scope)) { + parent::setScope(json_encode($scope)); + } else { + parent::setScope((string)$scope); + } + } + + public function getName(): string { + return parent::getName(); + } + + public function setName(string $name): void { + parent::setName($name); + } + + public function getRemember(): int { + return parent::getRemember(); + } + + public function setToken(string $token): void { + parent::setToken($token); + } + + public function setPassword(?string $password = null): void { + parent::setPassword($password); + } + + public function setExpires($expires): void { + parent::setExpires($expires); + } + + /** + * @return int|null + */ + public function getExpires() { + return parent::getExpires(); + } + + public function setPasswordInvalid(bool $invalid) { + parent::setPasswordInvalid($invalid); + } + + public function wipe(): void { + parent::setType(IToken::WIPE_TOKEN); + } +} diff --git a/lib/private/Authentication/Token/PublicKeyTokenMapper.php b/lib/private/Authentication/Token/PublicKeyTokenMapper.php new file mode 100644 index 00000000000..9aabd69e57a --- /dev/null +++ b/lib/private/Authentication/Token/PublicKeyTokenMapper.php @@ -0,0 +1,252 @@ +<?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 OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\Authentication\Token\IToken; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<PublicKeyToken> + */ +class PublicKeyTokenMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'authtoken'); + } + + /** + * Invalidate (delete) a given token + */ + public function invalidate(string $token) { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + /** + * @param int $olderThan + * @param int $type + * @param int|null $remember + */ + public function invalidateOld(int $olderThan, int $type = IToken::TEMPORARY_TOKEN, ?int $remember = null) { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $delete = $qb->delete($this->tableName) + ->where($qb->expr()->lt('last_activity', $qb->createNamedParameter($olderThan, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + if ($remember !== null) { + $delete->andWhere($qb->expr()->eq('remember', $qb->createNamedParameter($remember, IQueryBuilder::PARAM_INT))); + } + $delete->executeStatement(); + } + + public function invalidateLastUsedBefore(string $uid, int $before): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->lt('last_activity', $qb->createNamedParameter($before, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + + /** + * Get the user UID for the given token + * + * @throws DoesNotExistException + */ + public function getToken(string $token): PublicKeyToken { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + throw new DoesNotExistException('token does not exist'); + } + return PublicKeyToken::fromRow($data); + } + + /** + * Get the token for $id + * + * @throws DoesNotExistException + */ + public function getTokenById(int $id): PublicKeyToken { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + throw new DoesNotExistException('token does not exist'); + } + return PublicKeyToken::fromRow($data); + } + + /** + * Get all tokens of a user + * + * The provider may limit the number of result rows in case of an abuse + * where a high number of (session) tokens is generated + * + * @param string $uid + * @return PublicKeyToken[] + */ + public function getTokenByUser(string $uid): array { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->setMaxResults(1000); + $result = $qb->executeQuery(); + $data = $result->fetchAll(); + $result->closeCursor(); + + $entities = array_map(function ($row) { + return PublicKeyToken::fromRow($row); + }, $data); + + return $entities; + } + + public function getTokenByUserAndId(string $uid, int $id): ?string { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('token') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeQuery()->fetchOne() ?: null; + } + + /** + * delete all auth token which belong to a specific client if the client was deleted + * + * @param string $name + */ + public function deleteByName(string $name) { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('name', $qb->createNamedParameter($name), IQueryBuilder::PARAM_STR)) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } + + public function deleteTempToken(PublicKeyToken $except) { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($except->getUID()))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter(IToken::TEMPORARY_TOKEN))) + ->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($except->getId()))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } + + public function hasExpiredTokens(string $uid): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('password_invalid', $qb->createNamedParameter(true), IQueryBuilder::PARAM_BOOL)) + ->setMaxResults(1); + + $cursor = $qb->executeQuery(); + $data = $cursor->fetchAll(); + $cursor->closeCursor(); + + return count($data) === 1; + } + + /** + * Update the last activity timestamp + * + * In highly concurrent setups it can happen that two parallel processes + * trigger the update at (nearly) the same time. In that special case it's + * not necessary to hit the database with two actual updates. Therefore the + * target last activity is included in the WHERE clause with a few seconds + * of tolerance. + * + * Example: + * - process 1 (P1) reads the token at timestamp 1500 + * - process 1 (P2) reads the token at timestamp 1501 + * - activity update interval is 100 + * + * This means + * + * - P1 will see a last_activity smaller than the current time and update + * the token row + * - If P2 reads after P1 had written, it will see 1600 as last activity + * and the comparison on last_activity won't be truthy. This means no rows + * need to be updated a second time + * - If P2 reads before P1 had written, it will see 1501 as last activity, + * but the comparison on last_activity will still not be truthy and the + * token row is not updated a second time + * + * @param IToken $token + * @param int $now + */ + public function updateActivity(IToken $token, int $now): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update($this->getTableName()) + ->set('last_activity', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->lt('last_activity', $qb->createNamedParameter($now - 15, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT) + ); + $update->executeStatement(); + } + + public function updateHashesForUser(string $userId, string $passwordHash): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update($this->getTableName()) + ->set('password_hash', $qb->createNamedParameter($passwordHash)) + ->where( + $qb->expr()->eq('uid', $qb->createNamedParameter($userId)) + ); + $update->executeStatement(); + } + + public function getFirstTokenForUser(string $userId): ?PublicKeyToken { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($userId))) + ->setMaxResults(1) + ->orderBy('id'); + $result = $qb->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + return null; + } + return PublicKeyToken::fromRow($data); + } +} diff --git a/lib/private/Authentication/Token/PublicKeyTokenProvider.php b/lib/private/Authentication/Token/PublicKeyTokenProvider.php new file mode 100644 index 00000000000..12c3a1d535b --- /dev/null +++ b/lib/private/Authentication/Token/PublicKeyTokenProvider.php @@ -0,0 +1,566 @@ +<?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\Token\IToken as OCPIToken; +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 TOKEN_CACHE_TTL = 10; + + use TTransactional; + + /** @var PublicKeyTokenMapper */ + private $mapper; + + /** @var ICrypto */ + private $crypto; + + /** @var IConfig */ + private $config; + + private IDBConnection $db; + + /** @var LoggerInterface */ + private $logger; + + /** @var ITimeFactory */ + private $time; + + /** @var ICache */ + private $cache; + + /** @var IHasher */ + private $hasher; + + public function __construct(PublicKeyTokenMapper $mapper, + ICrypto $crypto, + IConfig $config, + IDBConnection $db, + LoggerInterface $logger, + ITimeFactory $time, + IHasher $hasher, + ICacheFactory $cacheFactory) { + $this->mapper = $mapper; + $this->crypto = $crypto; + $this->config = $config; + $this->db = $db; + $this->logger = $logger; + $this->time = $time; + + $this->cache = $cacheFactory->isLocalCacheAvailable() + ? $cacheFactory->createLocal('authtoken_') + : $cacheFactory->createInMemory(); + $this->hasher = $hasher; + } + + /** + * {@inheritDoc} + */ + 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, + ): 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); + + 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; + } + + 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); + } + + 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); + } + } + + 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, + ); + $this->cacheToken($newToken); + + $this->cacheInvalidHash($token->getToken()); + $this->mapper->delete($token); + + return $newToken; + }, $this->db); + } + + public function invalidateToken(string $token) { + $tokenHash = $this->hashToken($token); + $this->mapper->invalidate($this->hashToken($token)); + $this->mapper->invalidate($this->hashTokenWithEmptySecret($token)); + $this->cacheInvalidHash($tokenHash); + } + + 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()); + + } + + 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); + } + + public function invalidateLastUsedBefore(string $uid, int $before): void { + $this->mapper->invalidateLastUsedBefore($uid, $before); + } + + public function updateToken(OCPIToken $token) { + if (!($token instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + $this->mapper->update($token); + $this->cacheToken($token); + } + + 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); + } + } + + public function getTokenByUser(string $uid): array { + return $this->mapper->getTokenByUser($uid); + } + + 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); + } + + 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) { + // 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); + } + + 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): 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); + + return $dbToken; + } + + 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); + } + + 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) { + // 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) { + if (!isset($hashNeedsUpdate[$t->getPasswordHash()])) { + if ($t->getPasswordHash() === null) { + $hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true; + } elseif (!$this->hasher->verify(sha1($password) . $password, $t->getPasswordHash())) { + $hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true; + } else { + $hashNeedsUpdate[$t->getPasswordHash() ?: ''] = false; + } + } + $needsUpdating = $hashNeedsUpdate[$t->getPasswordHash() ?: ''] ?? 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)); + } +} diff --git a/lib/private/Authentication/Token/RemoteWipe.php b/lib/private/Authentication/Token/RemoteWipe.php new file mode 100644 index 00000000000..80ba330b66d --- /dev/null +++ b/lib/private/Authentication/Token/RemoteWipe.php @@ -0,0 +1,134 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +use OC\Authentication\Events\RemoteWipeFinished; +use OC\Authentication\Events\RemoteWipeStarted; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUser; +use Psr\Log\LoggerInterface; +use function array_filter; + +class RemoteWipe { + /** @var IProvider */ + private $tokenProvider; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IProvider $tokenProvider, + IEventDispatcher $eventDispatcher, + LoggerInterface $logger) { + $this->tokenProvider = $tokenProvider; + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + } + + /** + * @param IToken $token + * @return bool + * + * @throws InvalidTokenException + * @throws WipeTokenException + */ + public function markTokenForWipe(IToken $token): bool { + if (!$token instanceof IWipeableToken) { + return false; + } + + $token->wipe(); + $this->tokenProvider->updateToken($token); + + return true; + } + + /** + * @param IUser $user + * + * @return bool true if any tokens have been marked for remote wipe + */ + public function markAllTokensForWipe(IUser $user): bool { + $tokens = $this->tokenProvider->getTokenByUser($user->getUID()); + + /** @var IWipeableToken[] $wipeable */ + $wipeable = array_filter($tokens, function (IToken $token) { + return $token instanceof IWipeableToken; + }); + + if (empty($wipeable)) { + return false; + } + + foreach ($wipeable as $token) { + $token->wipe(); + $this->tokenProvider->updateToken($token); + } + + return true; + } + + /** + * @param string $token + * + * @return bool whether wiping was started + * @throws InvalidTokenException + * + */ + public function start(string $token): bool { + try { + $this->tokenProvider->getToken($token); + + // We expect a WipedTokenException here. If we reach this point this + // is an ordinary token + return false; + } catch (WipeTokenException $e) { + // Expected -> continue below + } + + $dbToken = $e->getToken(); + + $this->logger->info('user ' . $dbToken->getUID() . ' started a remote wipe'); + + $this->eventDispatcher->dispatch(RemoteWipeStarted::class, new RemoteWipeStarted($dbToken)); + + return true; + } + + /** + * @param string $token + * + * @return bool whether wiping could be finished + * @throws InvalidTokenException + */ + public function finish(string $token): bool { + try { + $this->tokenProvider->getToken($token); + + // We expect a WipedTokenException here. If we reach this point this + // is an ordinary token + return false; + } catch (WipeTokenException $e) { + // Expected -> continue below + } + + $dbToken = $e->getToken(); + + $this->tokenProvider->invalidateToken($token); + + $this->logger->info('user ' . $dbToken->getUID() . ' finished a remote wipe'); + $this->eventDispatcher->dispatch(RemoteWipeFinished::class, new RemoteWipeFinished($dbToken)); + + return true; + } +} diff --git a/lib/private/Authentication/Token/TokenCleanupJob.php b/lib/private/Authentication/Token/TokenCleanupJob.php new file mode 100644 index 00000000000..e6d1e69e9b4 --- /dev/null +++ b/lib/private/Authentication/Token/TokenCleanupJob.php @@ -0,0 +1,26 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Token; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; + +class TokenCleanupJob extends TimedJob { + private IProvider $provider; + + public function __construct(ITimeFactory $time, IProvider $provider) { + parent::__construct($time); + $this->provider = $provider; + // Run once a day at off-peak time + $this->setInterval(24 * 60 * 60); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + protected function run($argument) { + $this->provider->invalidateOldTokens(); + } +} |