aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Authentication/Token
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Authentication/Token')
-rw-r--r--lib/private/Authentication/Token/INamedToken.php19
-rw-r--r--lib/private/Authentication/Token/IProvider.php172
-rw-r--r--lib/private/Authentication/Token/IToken.php17
-rw-r--r--lib/private/Authentication/Token/IWipeableToken.php16
-rw-r--r--lib/private/Authentication/Token/Manager.php243
-rw-r--r--lib/private/Authentication/Token/PublicKeyToken.php219
-rw-r--r--lib/private/Authentication/Token/PublicKeyTokenMapper.php252
-rw-r--r--lib/private/Authentication/Token/PublicKeyTokenProvider.php566
-rw-r--r--lib/private/Authentication/Token/RemoteWipe.php134
-rw-r--r--lib/private/Authentication/Token/TokenCleanupJob.php26
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();
+ }
+}