diff --git a/core/Controller/LoginController.php b/core/Controller/LoginController.php index 5c11c4bba83dc..31135a2c3b045 100644 --- a/core/Controller/LoginController.php +++ b/core/Controller/LoginController.php @@ -13,6 +13,7 @@ use OC\AppFramework\Http\Request; use OC\Authentication\Login\Chain; use OC\Authentication\Login\LoginData; +use OC\Authentication\RememberLogin\RememberLoginTokenMapper; use OC\Authentication\WebAuthn\Manager as WebAuthnManager; use OC\User\Session; use OC_App; @@ -68,6 +69,7 @@ public function __construct( private IManager $manager, private IL10N $l10n, private IAppManager $appManager, + private RememberLoginTokenMapper $rememberLoginTokenMapper, ) { parent::__construct($appName, $request); } @@ -82,7 +84,11 @@ public function logout() { $loginToken = $this->request->getCookie('nc_token'); $uid = $this->userSession->getUser()?->getUID(); if ($loginToken !== null && $uid !== null) { - $this->config->deleteUserValue($uid, 'login_token', $loginToken); + $affectedRows = $this->rememberLoginTokenMapper->deleteByToken($loginToken); + if ($affectedRows < 1) { + // TODO: remove this after migration to 'remember_login_tokens' table is finished + $this->config->deleteUserValue($uid, 'login_token', $loginToken); + } } $this->userSession->logout(); diff --git a/core/Migrations/Version36000Date20260908184209.php b/core/Migrations/Version36000Date20260908184209.php new file mode 100644 index 0000000000000..45f0be72a8856 --- /dev/null +++ b/core/Migrations/Version36000Date20260908184209.php @@ -0,0 +1,61 @@ +hasTable('remember_login_tokens')) { + $table = $schema->createTable('remember_login_tokens'); + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('uid', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('token', Types::STRING, [ + 'notnull' => true, + 'length' => 200, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['token'], 'remember_login_tokens_token'); + $table->addIndex(['uid'], 'remember_login_tokens_uid'); + + return $schema; + } + + return null; + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 4467383ba6a9b..7e9a615975a64 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1324,6 +1324,8 @@ 'OC\\Authentication\\Login\\UserDisabledCheckCommand' => $baseDir . '/lib/private/Authentication/Login/UserDisabledCheckCommand.php', 'OC\\Authentication\\Login\\WebAuthnChain' => $baseDir . '/lib/private/Authentication/Login/WebAuthnChain.php', 'OC\\Authentication\\Notifications\\Notifier' => $baseDir . '/lib/private/Authentication/Notifications/Notifier.php', + 'OC\\Authentication\\RememberLogin\\RememberLoginToken' => $baseDir . '/lib/private/Authentication/RememberLogin/RememberLoginToken.php', + 'OC\\Authentication\\RememberLogin\\RememberLoginTokenMapper' => $baseDir . '/lib/private/Authentication/RememberLogin/RememberLoginTokenMapper.php', 'OC\\Authentication\\Token\\INamedToken' => $baseDir . '/lib/private/Authentication/Token/INamedToken.php', 'OC\\Authentication\\Token\\IProvider' => $baseDir . '/lib/private/Authentication/Token/IProvider.php', 'OC\\Authentication\\Token\\IToken' => $baseDir . '/lib/private/Authentication/Token/IToken.php', @@ -1733,6 +1735,7 @@ 'OC\\Core\\Migrations\\Version34000Date20260518163022' => $baseDir . '/core/Migrations/Version34000Date20260518163022.php', 'OC\\Core\\Migrations\\Version34000Date20260521110333' => $baseDir . '/core/Migrations/Version34000Date20260521110333.php', 'OC\\Core\\Migrations\\Version35000Date20260527162338' => $baseDir . '/core/Migrations/Version35000Date20260527162338.php', + 'OC\\Core\\Migrations\\Version36000Date20260908184209' => $baseDir . '/core/Migrations/Version36000Date20260908184209.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php', 'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 27b13860aec74..be0af6c217b72 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1365,6 +1365,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Authentication\\Login\\UserDisabledCheckCommand' => __DIR__ . '/../../..' . '/lib/private/Authentication/Login/UserDisabledCheckCommand.php', 'OC\\Authentication\\Login\\WebAuthnChain' => __DIR__ . '/../../..' . '/lib/private/Authentication/Login/WebAuthnChain.php', 'OC\\Authentication\\Notifications\\Notifier' => __DIR__ . '/../../..' . '/lib/private/Authentication/Notifications/Notifier.php', + 'OC\\Authentication\\RememberLogin\\RememberLoginToken' => __DIR__ . '/../../..' . '/lib/private/Authentication/RememberLogin/RememberLoginToken.php', + 'OC\\Authentication\\RememberLogin\\RememberLoginTokenMapper' => __DIR__ . '/../../..' . '/lib/private/Authentication/RememberLogin/RememberLoginTokenMapper.php', 'OC\\Authentication\\Token\\INamedToken' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/INamedToken.php', 'OC\\Authentication\\Token\\IProvider' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/IProvider.php', 'OC\\Authentication\\Token\\IToken' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/IToken.php', @@ -1774,6 +1776,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version34000Date20260518163022' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260518163022.php', 'OC\\Core\\Migrations\\Version34000Date20260521110333' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260521110333.php', 'OC\\Core\\Migrations\\Version35000Date20260527162338' => __DIR__ . '/../../..' . '/core/Migrations/Version35000Date20260527162338.php', + 'OC\\Core\\Migrations\\Version36000Date20260908184209' => __DIR__ . '/../../..' . '/core/Migrations/Version36000Date20260908184209.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php', 'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php', diff --git a/lib/private/Authentication/RememberLogin/RememberLoginToken.php b/lib/private/Authentication/RememberLogin/RememberLoginToken.php new file mode 100644 index 0000000000000..1a92b3ba57db8 --- /dev/null +++ b/lib/private/Authentication/RememberLogin/RememberLoginToken.php @@ -0,0 +1,29 @@ + + */ +class RememberLoginTokenMapper extends Repository { + public const string entityClass = RememberLoginToken::class; + + public function __construct( + IDBConnection $connection, + EntityManager $entityManager, + private readonly ISnowflakeGenerator $snowflakeGenerator, + private readonly IConfig $config, + ) { + /** @psalm-suppress InternalMethod */ + parent::__construct($connection, $entityManager); + } + + #[Override] + public function insert(object $entity): object { + /** @var RememberLoginToken $entity */ + $entity->token = $this->hashToken($entity->token); + + return parent::insert($entity); + } + + /** + * @throws DoesNotExistException + */ + public function findByToken(string $token): RememberLoginToken { + return $this->findOneBy(['token' => $this->hashToken($token)]); + } + + public function deleteByToken(string $token): int { + return $this->deleteBy(['token' => $this->hashToken($token)]); + } + + /** + * Removes every remembered login token for given user + */ + public function deleteByUid(string $uid): int { + return $this->deleteBy(['uid' => $uid]); + } + + /** + * Updates old token with the new one and generates a new snowflake ID, + * refreshing the creation timestamp encoded in it + * + * @return int Number of updated rows + */ + public function rotateToken(string $oldToken, string $newToken): int { + $qb = $this->connection->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('id', $qb->createNamedParameter($this->snowflakeGenerator->nextId())) + ->set('token', $qb->createNamedParameter($this->hashToken($newToken))) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($this->hashToken($oldToken)))); + + return $qb->executeStatement(); + } + + /** + * Removes every remembered login token older than the given timestamp + */ + public function deleteOlderThan(int $timestamp): int { + $qb = $this->connection->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('id', $qb->createNamedParameter($this->snowflakeGenerator->minForTimeId($timestamp)))); + + return $qb->executeStatement(); + } + + private function hashToken(string $token): string { + return hash('sha512', $token . $this->config->getSystemValueString('secret')); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index ab4f6c93cb68a..7916a1387cf69 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -22,6 +22,7 @@ use OC\Authentication\Listeners\LoginFailedListener; use OC\Authentication\Listeners\UserLoggedInListener; use OC\Authentication\LoginCredentials\Store; +use OC\Authentication\RememberLogin\RememberLoginTokenMapper; use OC\Authentication\Token\IProvider; use OC\Authentication\TwoFactorAuth\Registry; use OC\Avatar\AvatarManager; @@ -435,8 +436,10 @@ public function __construct( // might however be called when Nextcloud is not yet setup. if (\OCP\Server::get(SystemConfig::class)->getValue('installed', false)) { $provider = $c->get(IProvider::class); + $rememberLoginTokenMapper = $c->get(RememberLoginTokenMapper::class); } else { $provider = null; + $rememberLoginTokenMapper = null; } $userSession = new Session( @@ -449,6 +452,7 @@ public function __construct( $c->get(ILockdownManager::class), $c->get(LoggerInterface::class), $c->get(IEventDispatcher::class), + $rememberLoginTokenMapper, ); /** @deprecated 21.0.0 use BeforeUserCreatedEvent event with the IEventDispatcher instead */ $userSession->listen('\OC\User', 'preCreateUser', function ($uid, $password): void { diff --git a/lib/private/User/BackgroundJobs/CleanupLoginTokens.php b/lib/private/User/BackgroundJobs/CleanupLoginTokens.php index 4adbfb3dad5c7..8a0f4bb55530e 100644 --- a/lib/private/User/BackgroundJobs/CleanupLoginTokens.php +++ b/lib/private/User/BackgroundJobs/CleanupLoginTokens.php @@ -9,6 +9,7 @@ namespace OC\User\BackgroundJobs; +use OC\Authentication\RememberLogin\RememberLoginTokenMapper; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use OCP\IConfig; @@ -18,6 +19,7 @@ class CleanupLoginTokens extends TimedJob { public function __construct( ITimeFactory $time, private readonly IDBConnection $connection, + private readonly RememberLoginTokenMapper $rememberLoginTokenMapper, private readonly IConfig $config, ) { parent::__construct($time); @@ -28,6 +30,10 @@ public function __construct( #[\Override] protected function run($argument): void { $rememberMeMaxAge = $this->config->getSystemValueInt('remember_login_cookie_lifetime', 60 * 60 * 24 * 15); + + $this->rememberLoginTokenMapper->deleteOlderThan(time() - $rememberMeMaxAge); + + // TODO: remove this after migration to 'remember_login_tokens' table is finished $qb = $this->connection->getQueryBuilder(); $qb ->delete('preferences') diff --git a/lib/private/User/Listeners/BeforeUserDeletedListener.php b/lib/private/User/Listeners/BeforeUserDeletedListener.php index 163ec2bb2cdc6..3d0cc255b9e89 100644 --- a/lib/private/User/Listeners/BeforeUserDeletedListener.php +++ b/lib/private/User/Listeners/BeforeUserDeletedListener.php @@ -9,6 +9,7 @@ namespace OC\User\Listeners; +use OC\Authentication\RememberLogin\RememberLoginTokenMapper; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\NotFoundException; @@ -25,6 +26,7 @@ public function __construct( private LoggerInterface $logger, private IAvatarManager $avatarManager, private ICredentialsManager $credentialsManager, + private RememberLoginTokenMapper $rememberLoginTokenMapper, ) { } @@ -50,5 +52,7 @@ public function handle(Event $event): void { } // Delete storages credentials on user deletion $this->credentialsManager->erase($user->getUID()); + // Delete remember login tokens on user deletion + $this->rememberLoginTokenMapper->deleteByUid($user->getUID()); } } diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 578f8e75b9e25..becc830dac8eb 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -12,6 +12,8 @@ use OC\Authentication\Events\LoginFailed; use OC\Authentication\Exceptions\PasswordlessTokenException; use OC\Authentication\Exceptions\PasswordLoginForbiddenException; +use OC\Authentication\RememberLogin\RememberLoginToken; +use OC\Authentication\RememberLogin\RememberLoginTokenMapper; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; use OC\Authentication\Token\PublicKeyToken; @@ -22,6 +24,7 @@ use OC\Security\CSRF\CsrfTokenManager; use OC_User; use OCA\DAV\Connector\Sabre\Auth; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\Exceptions\ExpiredTokenException; @@ -82,6 +85,7 @@ public function __construct( private ILockdownManager $lockdownManager, private LoggerInterface $logger, private IEventDispatcher $dispatcher, + private ?RememberLoginTokenMapper $rememberLoginTokenMapper, ) { } @@ -893,15 +897,31 @@ public function loginWithCookie($uid, $currentToken, $oldSessionId) { return false; } - // get stored tokens - $tokens = $this->config->getUserKeys($uid, 'login_token'); - // test cookies token against stored tokens - if (!in_array($currentToken, $tokens, true)) { - $this->logger->info('Tried to log in but could not verify token', [ - 'app' => 'core', - 'user' => $uid, - ]); - return false; + $isLegacyRememberLoginToken = false; + try { + // get stored token + $rememberLoginToken = $this->rememberLoginTokenMapper->findByToken($currentToken); + if ($rememberLoginToken->uid !== $uid) { + $this->logger->warning('Tried to login using remember-me token token from a different user', [ + 'app' => 'core', + 'user' => $uid, + ]); + return false; + } + } catch (DoesNotExistException $ex) { + // TODO: remove this after migration to 'remember_login_tokens' table is finished + $legacyRememberLoginTokens = $this->config->getUserKeys($uid, 'login_token'); + $isLegacyRememberLoginToken = in_array($currentToken, $legacyRememberLoginTokens, true); + if ($isLegacyRememberLoginToken) { + // remove token from 'preferences' table + $this->config->deleteUserValue($uid, 'login_token', $currentToken); + } else { + $this->logger->info('Tried to log in but could not verify token', [ + 'app' => 'core', + 'user' => $uid, + ]); + return false; + } } try { @@ -923,10 +943,15 @@ public function loginWithCookie($uid, $currentToken, $oldSessionId) { return false; } - // replace successfully used token with a new one - $this->config->deleteUserValue($uid, 'login_token', $currentToken); - $newToken = $this->random->generate(32); - $this->config->setUserValue($uid, 'login_token', $newToken, (string)$this->timeFactory->getTime()); + if ($isLegacyRememberLoginToken) { + // legacy token was removed from 'preferences' table + // create new one on 'remember_login_tokens' table + $newToken = $this->createRememberLoginToken($uid); + } else { + // replace successfully used token with a new one + $newToken = $this->random->generate(32); + $this->rememberLoginTokenMapper->rotateToken($currentToken, $newToken); + } $this->logger->debug('Remember-me token replaced', [ 'app' => 'core', 'user' => $uid, @@ -977,11 +1002,23 @@ public function loginWithCookie($uid, $currentToken, $oldSessionId) { * @param IUser $user */ public function createRememberMeToken(IUser $user) { - $token = $this->random->generate(32); - $this->config->setUserValue($user->getUID(), 'login_token', $token, (string)$this->timeFactory->getTime()); + $token = $this->createRememberLoginToken($user->getUID()); $this->setMagicInCookie($user->getUID(), $token); } + /** + * Generates a new remember login token, stores it for the given user and returns the plain token + */ + private function createRememberLoginToken(string $uid): string { + $token = $this->random->generate(32); + $rememberLoginToken = new RememberLoginToken(); + $rememberLoginToken->uid = $uid; + $rememberLoginToken->token = $token; + $this->rememberLoginTokenMapper->insert($rememberLoginToken); + + return $token; + } + /** * logout the user from the session */ diff --git a/tests/Core/Controller/LoginControllerTest.php b/tests/Core/Controller/LoginControllerTest.php index 3c8fbd849b936..6b2b674ca1dc1 100644 --- a/tests/Core/Controller/LoginControllerTest.php +++ b/tests/Core/Controller/LoginControllerTest.php @@ -13,6 +13,7 @@ use OC\Authentication\Login\Chain as LoginChain; use OC\Authentication\Login\LoginData; use OC\Authentication\Login\LoginResult; +use OC\Authentication\RememberLogin\RememberLoginTokenMapper; use OC\Authentication\TwoFactorAuth\Manager; use OC\Core\Controller\LoginController; use OC\User\Session; @@ -81,6 +82,8 @@ class LoginControllerTest extends TestCase { /** @var IAppManager|MockObject */ private $appManager; + private RememberLoginTokenMapper&MockObject $rememberLoginTokenMapper; + #[\Override] protected function setUp(): void { parent::setUp(); @@ -98,6 +101,7 @@ protected function setUp(): void { $this->notificationManager = $this->createMock(IManager::class); $this->l = $this->createMock(IL10N::class); $this->appManager = $this->createMock(IAppManager::class); + $this->rememberLoginTokenMapper = $this->createMock(RememberLoginTokenMapper::class); $this->l->expects($this->any()) ->method('t') @@ -131,6 +135,7 @@ protected function setUp(): void { $this->notificationManager, $this->l, $this->appManager, + $this->rememberLoginTokenMapper, ); } @@ -147,9 +152,9 @@ public function testLogoutWithoutToken(): void { ->expects($this->once()) ->method('isUserAgent') ->willReturn(false); - $this->config + $this->rememberLoginTokenMapper ->expects($this->never()) - ->method('deleteUserValue'); + ->method('deleteByToken'); $this->urlGenerator ->expects($this->once()) ->method('linkToRouteAbsolute') @@ -206,10 +211,10 @@ public function testLogoutWithToken(): void { ->expects($this->once()) ->method('getUser') ->willReturn($user); - $this->config + $this->rememberLoginTokenMapper ->expects($this->once()) - ->method('deleteUserValue') - ->with('JohnDoe', 'login_token', 'MyLoginToken'); + ->method('deleteByToken') + ->with('MyLoginToken'); $this->urlGenerator ->expects($this->once()) ->method('linkToRouteAbsolute') diff --git a/tests/lib/User/SessionTest.php b/tests/lib/User/SessionTest.php index f466569eb9b3c..9e20d15d35a1d 100644 --- a/tests/lib/User/SessionTest.php +++ b/tests/lib/User/SessionTest.php @@ -13,6 +13,8 @@ use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Exceptions\PasswordlessTokenException; use OC\Authentication\Exceptions\PasswordLoginForbiddenException; +use OC\Authentication\RememberLogin\RememberLoginToken; +use OC\Authentication\RememberLogin\RememberLoginTokenMapper; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; use OC\Authentication\Token\PublicKeyToken; @@ -23,6 +25,7 @@ use OC\User\Session; use OC\User\User; use OCA\DAV\Connector\Sabre\Auth; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; use OCP\ICacheFactory; @@ -68,6 +71,8 @@ class SessionTest extends \Test\TestCase { private $logger; /** @var IEventDispatcher|MockObject */ private $dispatcher; + /** @var RememberLoginTokenMapper|MockObject */ + private $rememberLoginTokenMapper; #[\Override] protected function setUp(): void { @@ -86,6 +91,7 @@ protected function setUp(): void { $this->lockdownManager = $this->createMock(ILockdownManager::class); $this->logger = $this->createMock(LoggerInterface::class); $this->dispatcher = $this->createMock(IEventDispatcher::class); + $this->rememberLoginTokenMapper = $this->createMock(RememberLoginTokenMapper::class); $this->userSession = $this->getMockBuilder(Session::class) ->setConstructorArgs([ $this->manager, @@ -96,7 +102,8 @@ protected function setUp(): void { $this->random, $this->lockdownManager, $this->logger, - $this->dispatcher + $this->dispatcher, + $this->rememberLoginTokenMapper, ]) ->onlyMethods([ 'setMagicInCookie', @@ -120,7 +127,7 @@ public function testIsLoggedIn($isLoggedIn): void { $manager = $this->createMock(Manager::class); $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods([ 'getUser' ]) @@ -147,7 +154,7 @@ public function testSetUser(): void { ->method('getUID') ->willReturn('foo'); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $userSession->setUser($user); } @@ -204,7 +211,7 @@ public function testLoginValidPasswordEnabled(): void { ->willReturn($user); $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods([ 'prepareUserLogin' ]) @@ -267,7 +274,7 @@ public function testLoginValidPasswordDisabled(): void { $this->dispatcher->expects($this->never()) ->method('dispatch'); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $userSession->login('foo', 'bar'); } @@ -286,7 +293,7 @@ public function testLoginInvalidPassword(): void { ]) ->getMock(); $backend = $this->createMock(\Test\Util\User\Dummy::class); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $user = $this->createMock(IUser::class); @@ -329,7 +336,7 @@ public function testPasswordlessLoginNoLastCheckUpdate(): void { $this->createMock(LoggerInterface::class), ]) ->getMock(); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('foo'); @@ -373,7 +380,7 @@ public function testLoginLastCheckUpdate(): void { $this->createMock(LoggerInterface::class), ]) ->getMock(); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('foo'); @@ -406,7 +413,7 @@ public function testLoginLastCheckUpdate(): void { public function testLoginNonExisting(): void { $session = $this->createMock(Memory::class); $manager = $this->createMock(Manager::class); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $session->expects($this->never()) ->method('set'); @@ -434,7 +441,7 @@ public function testLogClientInNoTokenPasswordWith2fa(): void { /** @var Session $userSession */ $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods(['login', 'supportsCookies', 'createSessionToken', 'getUser']) ->getMock(); @@ -479,7 +486,7 @@ public function testLogClientInUnexist(): void { /** @var Session $userSession */ $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods(['login', 'supportsCookies', 'createSessionToken', 'getUser']) ->getMock(); @@ -505,7 +512,7 @@ public function testLogClientInWithTokenPassword(): void { /** @var Session $userSession */ $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods(['login', 'supportsCookies', 'createSessionToken', 'getUser']) ->getMock(); @@ -547,7 +554,7 @@ public function testLogClientInNoTokenPasswordNo2fa(): void { /** @var Session $userSession */ $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods(['login', 'isTwoFactorEnforced']) ->getMock(); @@ -750,7 +757,7 @@ public function testRememberLoginValidToken(): void { $userSession = $this->getMockBuilder(Session::class) //override, otherwise tests will fail because of setcookie() ->onlyMethods(['setMagicInCookie', 'setLoginName']) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->getMock(); $user = $this->createMock(IUser::class); @@ -764,20 +771,22 @@ public function testRememberLoginValidToken(): void { ->method('get') ->with('foo') ->willReturn($user); - $this->config->expects($this->once()) - ->method('getUserKeys') - ->with('foo', 'login_token') - ->willReturn([$token]); - $this->config->expects($this->once()) - ->method('deleteUserValue') - ->with('foo', 'login_token', $token); + + $storedRememberLoginToken = new RememberLoginToken(); + $storedRememberLoginToken->uid = 'foo'; + $storedRememberLoginToken->token = $token; + + $this->rememberLoginTokenMapper->expects($this->once()) + ->method('findByToken') + ->with($token) + ->willReturn($storedRememberLoginToken); $this->random->expects($this->once()) ->method('generate') ->with(32) ->willReturn('abcdefg123456'); - $this->config->expects($this->once()) - ->method('setUserValue') - ->with('foo', 'login_token', 'abcdefg123456', 10000); + $this->rememberLoginTokenMapper->expects($this->once()) + ->method('rotateToken') + ->with($token, 'abcdefg123456'); $tokenObject = $this->createMock(IToken::class); $tokenObject->expects($this->once()) @@ -846,7 +855,7 @@ public function testRememberLoginInvalidSessionToken(): void { $userSession = $this->getMockBuilder(Session::class) //override, otherwise tests will fail because of setcookie() ->onlyMethods(['setMagicInCookie']) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->getMock(); $user = $this->createMock(IUser::class); @@ -860,15 +869,17 @@ public function testRememberLoginInvalidSessionToken(): void { ->method('get') ->with('foo') ->willReturn($user); - $this->config->expects($this->once()) - ->method('getUserKeys') - ->with('foo', 'login_token') - ->willReturn([$token]); - $this->config->expects($this->once()) - ->method('deleteUserValue') - ->with('foo', 'login_token', $token); - $this->config->expects($this->once()) - ->method('setUserValue'); // TODO: mock new random value + + $storedRememberLoginToken = new RememberLoginToken(); + $storedRememberLoginToken->uid = 'foo'; + $storedRememberLoginToken->token = $token; + + $this->rememberLoginTokenMapper->expects($this->once()) + ->method('findByToken') + ->with($token) + ->willReturn($storedRememberLoginToken); + $this->rememberLoginTokenMapper->expects($this->once()) + ->method('rotateToken'); $session->expects($this->once()) ->method('getId') @@ -920,7 +931,7 @@ public function testRememberLoginInvalidToken(): void { $userSession = $this->getMockBuilder(Session::class) //override, otherwise tests will fail because of setcookie() ->onlyMethods(['setMagicInCookie']) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->getMock(); $user = $this->createMock(IUser::class); @@ -933,13 +944,16 @@ public function testRememberLoginInvalidToken(): void { ->method('get') ->with('foo') ->willReturn($user); + $this->rememberLoginTokenMapper->expects($this->once()) + ->method('findByToken') + ->with($token) + ->willThrowException(new DoesNotExistException('')); $this->config->expects($this->once()) ->method('getUserKeys') ->with('foo', 'login_token') - ->willReturn(['anothertoken']); - $this->config->expects($this->never()) - ->method('deleteUserValue') - ->with('foo', 'login_token', $token); + ->willReturn([]); + $this->rememberLoginTokenMapper->expects($this->never()) + ->method('deleteByToken'); $this->tokenProvider->expects($this->never()) ->method('renewSessionToken'); @@ -973,7 +987,7 @@ public function testRememberLoginInvalidUser(): void { $userSession = $this->getMockBuilder(Session::class) //override, otherwise tests will fail because of setcookie() ->onlyMethods(['setMagicInCookie']) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->getMock(); $token = 'goodToken'; $oldSessionId = 'sess321'; @@ -984,10 +998,8 @@ public function testRememberLoginInvalidUser(): void { ->method('get') ->with('foo') ->willReturn(null); - $this->config->expects($this->never()) - ->method('getUserKeys') - ->with('foo', 'login_token') - ->willReturn(['anothertoken']); + $this->rememberLoginTokenMapper->expects($this->never()) + ->method('findByToken'); $this->tokenProvider->expects($this->never()) ->method('renewSessionToken'); @@ -1021,7 +1033,7 @@ public function testActiveUserAfterSetSession(): void { $session = new Memory(); $session->set('user_id', 'foo'); $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods([ 'validateSession' ]) @@ -1041,7 +1053,7 @@ public function testCreateSessionToken(): void { $manager = $this->createMock(Manager::class); $session = $this->createMock(ISession::class); $user = $this->createMock(IUser::class); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $requestId = $this->createMock(IRequestId::class); $config = $this->createMock(IConfig::class); @@ -1082,7 +1094,7 @@ public function testCreateRememberedSessionToken(): void { $manager = $this->createMock(Manager::class); $session = $this->createMock(ISession::class); $user = $this->createMock(IUser::class); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $requestId = $this->createMock(IRequestId::class); $config = $this->createMock(IConfig::class); @@ -1126,7 +1138,7 @@ public function testCreateSessionTokenWithTokenPassword(): void { $session = $this->createMock(ISession::class); $token = $this->createMock(IToken::class); $user = $this->createMock(IUser::class); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $requestId = $this->createMock(IRequestId::class); $config = $this->createMock(IConfig::class); @@ -1173,7 +1185,7 @@ public function testCreateSessionTokenWithNonExistentUser(): void { ->disableOriginalConstructor() ->getMock(); $session = $this->createMock(ISession::class); - $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper); $request = $this->createMock(IRequest::class); $uid = 'user123'; @@ -1199,10 +1211,13 @@ public function testCreateRememberMeToken(): void { ->method('generate') ->with(32) ->willReturn('LongRandomToken'); - $this->config + $this->rememberLoginTokenMapper ->expects($this->once()) - ->method('setUserValue') - ->with('UserUid', 'login_token', 'LongRandomToken', 10000); + ->method('insert') + ->with($this->callback(function (RememberLoginToken $rememberLoginToken): bool { + return $rememberLoginToken->uid === 'UserUid' + && $rememberLoginToken->token === 'LongRandomToken'; + })); $this->userSession ->expects($this->once()) ->method('setMagicInCookie') @@ -1246,7 +1261,8 @@ public function testTryBasicAuthLoginValid(): void { $this->random, $this->lockdownManager, $this->logger, - $this->dispatcher + $this->dispatcher, + $this->rememberLoginTokenMapper, ]) ->onlyMethods([ 'logClientIn', @@ -1297,7 +1313,8 @@ public function testTryBasicAuthLoginNoLogin(): void { $this->random, $this->lockdownManager, $this->logger, - $this->dispatcher + $this->dispatcher, + $this->rememberLoginTokenMapper, ]) ->onlyMethods([ 'logClientIn', @@ -1326,7 +1343,7 @@ public function testLogClientInThrottlerUsername(): void { /** @var Session $userSession */ $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods(['login', 'supportsCookies', 'createSessionToken', 'getUser']) ->getMock(); @@ -1373,7 +1390,7 @@ public function testLogClientInThrottlerEmail(): void { /** @var Session $userSession */ $userSession = $this->getMockBuilder(Session::class) - ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher, $this->rememberLoginTokenMapper]) ->onlyMethods(['login', 'supportsCookies', 'createSessionToken', 'getUser']) ->getMock(); diff --git a/version.php b/version.php index 31e5cb2bcda4c..ca54c28709bc6 100644 --- a/version.php +++ b/version.php @@ -11,7 +11,7 @@ // between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level // when updating major/minor version number. -$OC_Version = [36, 0, 0, 0]; +$OC_Version = [36, 0, 0, 1]; // The human-readable string $OC_VersionString = '36.0.0 dev';