From 1c6c5f8ceeda1e6d7228b63b7f989f2ecd8c48c8 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Tue, 6 Oct 2026 11:17:33 -0400 Subject: [PATCH] feat: per-device remember-me tokens A single remember_token column per user meant a remember-me login on one device rotated the token and logged every other device out. RememberTokenStorageInterface is now a real per-device store (store, findBySelector, compare-and-swap rotateValidator, deleteBySelector, plus the existing clear methods). When it is bound, SessionGuard issues a "selector:validator" cookie per device, looks tokens up by guard and selector, compares the validator hash with hash_equals, rotates only the validator (keeping the #503 fixed expiry), deletes only the current device's row on logout, and records the User-Agent. Without a binding it falls back to the single-column path, and an old "id|token" cookie is moved onto its own device row on first use. marko/admin-auth binds RememberTokenRepository over a new remember_tokens table (RememberToken entity, created by db:migrate), so auth:clear-tokens now has storage to purge expired rows from. AuthManager is built by a module factory so the storage is optional. Fixes #513 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1 --- packages/admin-auth/module.php | 4 + .../admin-auth/src/Entity/RememberToken.php | 59 ++++ .../Repository/RememberTokenRepository.php | 117 +++++++ .../SqliteRememberTokenConnection.php | 96 ++++++ .../tests/Integration/AdminAuthSchema.php | 13 +- .../Integration/MySql/FreshMigrateTest.php | 4 +- .../Integration/PgSql/FreshMigrateTest.php | 2 +- .../RememberTokenRepositoryTest.php | 202 +++++++++++ packages/authentication/module.php | 23 ++ packages/authentication/src/AuthManager.php | 6 + .../RememberTokenStorageInterface.php | 49 +++ .../authentication/src/Guard/SessionGuard.php | 239 ++++++++++++- .../src/Token/RememberTokenManager.php | 10 + .../src/Token/RememberTokenRecord.php | 27 ++ .../Fixtures/InMemoryRememberTokenStorage.php | 98 ++++++ .../Integration/RememberMeIntegrationTest.php | 24 ++ .../Unit/Command/ClearTokensCommandTest.php | 161 +++------ .../Guard/SessionGuardDeviceRememberTest.php | 321 ++++++++++++++++++ .../Unit/Token/RememberTokenManagerTest.php | 10 + .../docs-markdown/docs/packages/admin-auth.md | 8 + .../docs/packages/authentication.md | 43 ++- .../vendor/marko/authentication/module.php | 21 ++ .../vendor/marko/authentication/module.php | 21 ++ 23 files changed, 1425 insertions(+), 133 deletions(-) create mode 100644 packages/admin-auth/src/Entity/RememberToken.php create mode 100644 packages/admin-auth/src/Repository/RememberTokenRepository.php create mode 100644 packages/admin-auth/tests/Fixtures/SqliteRememberTokenConnection.php create mode 100644 packages/admin-auth/tests/Unit/Repository/RememberTokenRepositoryTest.php create mode 100644 packages/authentication/src/Token/RememberTokenRecord.php create mode 100644 packages/authentication/tests/Fixtures/InMemoryRememberTokenStorage.php create mode 100644 packages/authentication/tests/Unit/Guard/SessionGuardDeviceRememberTest.php diff --git a/packages/admin-auth/module.php b/packages/admin-auth/module.php index 5b00867e4..fb0445448 100644 --- a/packages/admin-auth/module.php +++ b/packages/admin-auth/module.php @@ -12,8 +12,10 @@ use Marko\AdminAuth\Repository\AdminUserRepositoryInterface; use Marko\AdminAuth\Repository\PermissionRepository; use Marko\AdminAuth\Repository\PermissionRepositoryInterface; +use Marko\AdminAuth\Repository\RememberTokenRepository; use Marko\AdminAuth\Repository\RoleRepository; use Marko\AdminAuth\Repository\RoleRepositoryInterface; +use Marko\Authentication\Contracts\RememberTokenStorageInterface; return [ // No global UserProviderInterface binding: the admin guard gets AdminUserProvider @@ -24,6 +26,8 @@ AdminUserRepositoryInterface::class => AdminUserRepository::class, RoleRepositoryInterface::class => RoleRepository::class, PermissionRepositoryInterface::class => PermissionRepository::class, + // Per-device remember-me tokens (remember_tokens table) for every session guard, admin and frontend. + RememberTokenStorageInterface::class => RememberTokenRepository::class, ], 'singletons' => [ // Shared: permissions registered through one injected registry must be visible to every consumer. diff --git a/packages/admin-auth/src/Entity/RememberToken.php b/packages/admin-auth/src/Entity/RememberToken.php new file mode 100644 index 000000000..bf70eadf8 --- /dev/null +++ b/packages/admin-auth/src/Entity/RememberToken.php @@ -0,0 +1,59 @@ +guard, + userId: $this->userId, + selector: $this->selector, + validatorHash: $this->validatorHash, + expiresAt: $this->expiresAt, + userAgent: $this->userAgent, + ); + } +} diff --git a/packages/admin-auth/src/Repository/RememberTokenRepository.php b/packages/admin-auth/src/Repository/RememberTokenRepository.php new file mode 100644 index 000000000..e08e146a4 --- /dev/null +++ b/packages/admin-auth/src/Repository/RememberTokenRepository.php @@ -0,0 +1,117 @@ + + */ +class RememberTokenRepository extends Repository implements RememberTokenStorageInterface +{ + protected const string ENTITY_CLASS = RememberToken::class; + + /** + * @throws RepositoryException + */ + public function store( + RememberTokenRecord $token, + ): void { + $entity = new RememberToken(); + $entity->guard = $token->guard; + $entity->userId = (string) $token->userId; + $entity->selector = $token->selector; + $entity->validatorHash = $token->validatorHash; + $entity->expiresAt = $token->expiresAt; + $entity->userAgent = $token->userAgent; + $entity->createdAt = $this->now(); + + $this->save($entity); + } + + /** + * @throws EntityException + */ + public function findBySelector( + string $guard, + string $selector, + ): ?RememberTokenRecord { + return $this->findOneBy(['guard' => $guard, 'selector' => $selector])?->toRecord(); + } + + /** + * A compare-and-swap on the validator hash: of two requests rotating the same token at once, the second + * matches no row and gets false. + */ + public function rotateValidator( + string $guard, + string $selector, + string $currentValidatorHash, + string $newValidatorHash, + ): bool { + $sql = sprintf( + 'UPDATE %s SET %s = ? WHERE %s = ? AND %s = ? AND %s = ?', + $this->table(), + $this->column('validator_hash'), + $this->column('guard'), + $this->column('selector'), + $this->column('validator_hash'), + ); + + return $this->connection->execute($sql, [$newValidatorHash, $guard, $selector, $currentValidatorHash]) === 1; + } + + public function deleteBySelector( + string $guard, + string $selector, + ): void { + $this->connection->execute( + sprintf( + 'DELETE FROM %s WHERE %s = ? AND %s = ?', + $this->table(), + $this->column('guard'), + $this->column('selector'), + ), + [$guard, $selector], + ); + } + + public function clearExpiredTokens(): int + { + $now = $this->hydrator->toDatabaseValue($this->now(), $this->metadata->properties['expiresAt']); + + return $this->connection->execute( + sprintf('DELETE FROM %s WHERE %s <= ?', $this->table(), $this->column('expires_at')), + [$now], + ); + } + + public function clearAllTokens(): int + { + return $this->connection->execute(sprintf('DELETE FROM %s', $this->table())); + } + + private function table(): string + { + return $this->connection->quoteIdentifier('remember_tokens'); + } + + private function column( + string $name, + ): string { + return $this->connection->quoteIdentifier($name); + } +} diff --git a/packages/admin-auth/tests/Fixtures/SqliteRememberTokenConnection.php b/packages/admin-auth/tests/Fixtures/SqliteRememberTokenConnection.php new file mode 100644 index 000000000..89df2b0dc --- /dev/null +++ b/packages/admin-auth/tests/Fixtures/SqliteRememberTokenConnection.php @@ -0,0 +1,96 @@ +pdo = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->pdo->exec( + 'CREATE TABLE remember_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, guard TEXT NOT NULL, ' + . 'user_id TEXT NOT NULL, selector TEXT NOT NULL UNIQUE, validator_hash TEXT NOT NULL, ' + . 'expires_at TEXT NOT NULL, user_agent TEXT NULL, created_at TEXT NULL)', + ); + } + + /** + * @return list> Every stored row, ordered by id + */ + public function rows(): array + { + return $this->pdo->query('SELECT * FROM remember_tokens ORDER BY id')->fetchAll(); + } + + public function connect(): void {} + + public function disconnect(): void {} + + public function isConnected(): bool + { + return true; + } + + public function query( + string $sql, + array $bindings = [], + ): array { + $statement = $this->pdo->prepare($sql); + $statement->execute($bindings); + + return $statement->fetchAll(); + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + $statement = $this->pdo->prepare($sql); + $statement->execute($bindings); + + return $statement->rowCount(); + } + + public function prepare( + string $sql, + ): StatementInterface { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return (int) $this->pdo->lastInsertId(); + } + + public function driverName(): string + { + return 'sqlite'; + } + + public function supportsReturning(): bool + { + return false; + } + + public function quoteIdentifier( + string $identifier, + ): string { + return '"' . str_replace('"', '""', $identifier) . '"'; + } +} diff --git a/packages/admin-auth/tests/Integration/AdminAuthSchema.php b/packages/admin-auth/tests/Integration/AdminAuthSchema.php index 8a1e711b8..f855a1e6f 100644 --- a/packages/admin-auth/tests/Integration/AdminAuthSchema.php +++ b/packages/admin-auth/tests/Integration/AdminAuthSchema.php @@ -8,6 +8,7 @@ use Marko\AdminAuth\Entity\AdminUser; use Marko\AdminAuth\Entity\AdminUserRole; use Marko\AdminAuth\Entity\Permission; +use Marko\AdminAuth\Entity\RememberToken; use Marko\AdminAuth\Entity\Role; use Marko\AdminAuth\Entity\RolePermission; use Marko\AdminAuth\Repository\AdminUserRepository; @@ -44,14 +45,21 @@ * Builds the admin-auth tables on a real server for the PostgreSQL and MySQL/MariaDB integration tests, and the * repositories that use them. * - * All five tables are created from their entities, the only source of the admin-auth schema: create() runs the + * All six tables are created from their entities, the only source of the admin-auth schema: create() runs the * driver's generator over each entity in foreign-key order, and migrate() runs the real db:migrate command in a * temporary project that has marko/admin-auth installed, as a fresh install would. */ class AdminAuthSchema { /** @var list Tables in reverse dependency order, the order they are dropped in */ - public const array TABLES = ['admin_user_roles', 'role_permissions', 'admin_users', 'roles', 'permissions']; + public const array TABLES = [ + 'remember_tokens', + 'admin_user_roles', + 'role_permissions', + 'admin_users', + 'roles', + 'permissions', + ]; /** @var list Entities in foreign-key order, the order they are created in */ private const array ENTITIES = [ @@ -60,6 +68,7 @@ class AdminAuthSchema RolePermission::class, AdminUser::class, AdminUserRole::class, + RememberToken::class, ]; public static function drop( diff --git a/packages/admin-auth/tests/Integration/MySql/FreshMigrateTest.php b/packages/admin-auth/tests/Integration/MySql/FreshMigrateTest.php index 7a2515f23..6a15f68ce 100644 --- a/packages/admin-auth/tests/Integration/MySql/FreshMigrateTest.php +++ b/packages/admin-auth/tests/Integration/MySql/FreshMigrateTest.php @@ -72,7 +72,7 @@ )?->columns; expect($result['exitCode'])->toBe(0) - ->and($result['output'])->toContain('Applied 5 schema migration(s).') + ->and($result['output'])->toContain('Applied 6 schema migration(s).') ->and($missing)->toBe([]) ->and($uniqueIndexColumns('role_permissions', 'idx_role_permissions_unique')) ->toBe(['role_id', 'permission_id']) @@ -122,7 +122,7 @@ )); expect($result['exitCode'])->toBe(0) - ->and($result['output'])->toContain('Applied 5 schema migration(s).') + ->and($result['output'])->toContain('Applied 6 schema migration(s).') ->and($pivotKey('role_permissions'))->toBe(['id']) ->and($pivotKey('admin_user_roles'))->toBe(['id']) ->and($this->connection->query('SELECT id, role_id, permission_id FROM role_permissions')) diff --git a/packages/admin-auth/tests/Integration/PgSql/FreshMigrateTest.php b/packages/admin-auth/tests/Integration/PgSql/FreshMigrateTest.php index 51d999ea1..de1986bab 100644 --- a/packages/admin-auth/tests/Integration/PgSql/FreshMigrateTest.php +++ b/packages/admin-auth/tests/Integration/PgSql/FreshMigrateTest.php @@ -71,7 +71,7 @@ )?->columns; expect($result['exitCode'])->toBe(0) - ->and($result['output'])->toContain('Applied 5 schema migration(s).') + ->and($result['output'])->toContain('Applied 6 schema migration(s).') ->and($missing)->toBe([]) ->and($uniqueIndexColumns('role_permissions', 'idx_role_permissions_unique')) ->toBe(['role_id', 'permission_id']) diff --git a/packages/admin-auth/tests/Unit/Repository/RememberTokenRepositoryTest.php b/packages/admin-auth/tests/Unit/Repository/RememberTokenRepositoryTest.php new file mode 100644 index 000000000..af36a697a --- /dev/null +++ b/packages/admin-auth/tests/Unit/Repository/RememberTokenRepositoryTest.php @@ -0,0 +1,202 @@ +clock = new FakeClock('2026-03-01 09:00:00'); + $this->connection = new SqliteRememberTokenConnection(); + $this->repository = rememberTokenRepository($this->connection, $this->clock); +}); + +it('is bound as the remember token storage by the module', function (): void { + $module = require dirname(__DIR__, 3) . '/module.php'; + + expect($module['bindings'][RememberTokenStorageInterface::class])->toBe(RememberTokenRepository::class) + ->and($this->repository)->toBeInstanceOf(RememberTokenStorageInterface::class); +}); + +it('stores a device token and finds it again by guard and selector', function (): void { + $expiresAt = $this->clock->now()->modify('+30 days'); + + $this->repository->store(rememberTokenRecord('abc', $expiresAt, userId: 42)); + + $found = $this->repository->findBySelector('admin', 'abc'); + + expect($found)->toBeInstanceOf(RememberTokenRecord::class) + ->and($found->guard)->toBe('admin') + ->and($found->userId)->toBe('42') + ->and($found->selector)->toBe('abc') + ->and($found->validatorHash)->toBe(hash('sha256', 'validator-abc')) + ->and($found->expiresAt->getTimestamp())->toBe($expiresAt->getTimestamp()) + ->and($found->userAgent)->toBe('Safari on iPhone') + ->and($this->connection->rows()[0]['created_at'])->toBe('2026-03-01 09:00:00'); +}); + +it('does not find a selector under another guard', function (): void { + $this->repository->store(rememberTokenRecord('abc', $this->clock->now()->modify('+1 day'))); + + expect($this->repository->findBySelector('session', 'abc'))->toBeNull() + ->and($this->repository->findBySelector('admin', 'missing'))->toBeNull(); +}); + +it('rotates the validator only while the current hash still matches', function (): void { + $this->repository->store(rememberTokenRecord('abc', $this->clock->now()->modify('+1 day'))); + $current = hash('sha256', 'validator-abc'); + + $first = $this->repository->rotateValidator('admin', 'abc', $current, 'new-hash'); + $second = $this->repository->rotateValidator('admin', 'abc', $current, 'other-hash'); + + expect($first)->toBeTrue() + ->and($second)->toBeFalse() + ->and($this->repository->findBySelector('admin', 'abc')->validatorHash)->toBe('new-hash'); +}); + +it('deletes one device token and leaves the others', function (): void { + $expiresAt = $this->clock->now()->modify('+1 day'); + $this->repository->store(rememberTokenRecord('laptop', $expiresAt)); + $this->repository->store(rememberTokenRecord('phone', $expiresAt)); + + $this->repository->deleteBySelector('admin', 'laptop'); + + expect($this->repository->findBySelector('admin', 'laptop'))->toBeNull() + ->and($this->repository->findBySelector('admin', 'phone'))->not->toBeNull(); +}); + +it('purges only expired rows', function (): void { + $this->repository->store(rememberTokenRecord('expired', $this->clock->now()->modify('-1 second'))); + $this->repository->store(rememberTokenRecord('expiring-now', $this->clock->now())); + $this->repository->store(rememberTokenRecord('live', $this->clock->now()->modify('+1 minute'))); + + expect($this->repository->clearExpiredTokens())->toBe(2) + ->and(array_column($this->connection->rows(), 'selector'))->toBe(['live']); +}); + +it('purges expired rows through auth:clear-tokens', function (): void { + $this->repository->store(rememberTokenRecord('expired', $this->clock->now()->modify('-1 day'))); + $this->repository->store(rememberTokenRecord('live', $this->clock->now()->modify('+1 day'))); + $stream = fopen('php://memory', 'r+'); + + $exitCode = new ClearTokensCommand($this->repository)->execute( + new Input(['marko', 'auth:clear-tokens']), + new Output($stream), + ); + rewind($stream); + + expect($exitCode)->toBe(0) + ->and(stream_get_contents($stream))->toContain('Cleared 1 expired token(s).') + ->and(array_column($this->connection->rows(), 'selector'))->toBe(['live']); +}); + +it('clears every row with clearAllTokens', function (): void { + $this->repository->store(rememberTokenRecord('one', $this->clock->now()->modify('+1 day'))); + $this->repository->store(rememberTokenRecord('two', $this->clock->now()->modify('+1 day'), guard: 'session')); + + expect($this->repository->clearAllTokens())->toBe(2) + ->and($this->connection->rows())->toBe([]); +}); + +describe('SessionGuard over the remember_tokens table', function (): void { + beforeEach(function (): void { + $this->tokenManager = new RememberTokenManager($this->clock, lifetimeMinutes: 60); + $this->user = new FakeAuthenticatable(id: 5); + $this->provider = new FakeUserProvider([5 => $this->user]); + $this->guardFor = function (FakeCookieJar $cookieJar): SessionGuard { + $session = new FakeSession(); + $session->start(); + + return new SessionGuard( + session: $session, + provider: $this->provider, + name: 'admin', + cookieJar: $cookieJar, + tokenManager: $this->tokenManager, + rememberTokenStorage: $this->repository, + ); + }; + }); + + it('keeps two devices remembered and logs out only one of them', function (): void { + $laptop = new FakeCookieJar(); + $phone = new FakeCookieJar(); + ($this->guardFor)($laptop)->login($this->user, remember: true); + ($this->guardFor)($phone)->login($this->user, remember: true); + + $laptopReturns = ($this->guardFor)($laptop)->user(); + $phoneReturns = ($this->guardFor)($phone)->user(); + ($this->guardFor)($laptop)->logout(); + + expect($laptopReturns)->toBe($this->user) + ->and($phoneReturns)->toBe($this->user) + ->and($this->connection->rows())->toHaveCount(1) + ->and(($this->guardFor)($laptop)->user())->toBeNull() + ->and(($this->guardFor)($phone)->user())->toBe($this->user); + }); + + it('rejects a tampered validator', function (): void { + $cookieJar = new FakeCookieJar(); + ($this->guardFor)($cookieJar)->login($this->user, remember: true); + $selector = explode(':', (string) $cookieJar->get('remember_admin'))[0]; + $cookieJar->set('remember_admin', $selector . ':' . str_repeat('f', 64)); + + expect(($this->guardFor)($cookieJar)->user())->toBeNull() + ->and($this->connection->rows())->toHaveCount(1); + }); + + it('deletes a device token that has expired when it is presented', function (): void { + $cookieJar = new FakeCookieJar(); + ($this->guardFor)($cookieJar)->login($this->user, remember: true); + + $this->clock->travel('+61 minutes'); + + expect(($this->guardFor)($cookieJar)->user())->toBeNull() + ->and($this->connection->rows())->toBe([]) + ->and($this->repository->clearExpiredTokens())->toBe(0); + }); +}); diff --git a/packages/authentication/module.php b/packages/authentication/module.php index 6455a70e7..2a40c3d6f 100644 --- a/packages/authentication/module.php +++ b/packages/authentication/module.php @@ -8,6 +8,7 @@ use Marko\Authentication\Contracts\GuardInterface; use Marko\Authentication\Contracts\LoginThrottleInterface; use Marko\Authentication\Contracts\PasswordHasherInterface; +use Marko\Authentication\Contracts\RememberTokenStorageInterface; use Marko\Authentication\Cookie\RequestCookieJar; use Marko\Authentication\Guard\GuardDriverRegistry; use Marko\Authentication\Hashing\BcryptPasswordHasher; @@ -17,11 +18,14 @@ use Marko\Authentication\Throttle\LoginThrottle; use Marko\Authentication\Throttle\NullLoginThrottle; use Marko\Authentication\Token\RememberTokenManager; +use Marko\Authentication\UserProviderResolver; use Marko\Cache\Contracts\CacheInterface; use Marko\Core\Container\ContainerInterface; +use Marko\Core\Event\EventDispatcherInterface; use Marko\Core\Module\ModuleManifest; use Marko\Core\Module\ModuleRepositoryInterface; use Marko\RateLimiter\Contracts\RateLimitKeyResolverInterface; +use Marko\Session\Contracts\SessionInterface; use Psr\Clock\ClockInterface; return [ @@ -58,6 +62,25 @@ CookieJarInterface::class => function (ContainerInterface $container): CookieJarInterface { return $container->get(RequestCookieJar::class); }, + // Per-device remember-me tokens are used when a module (e.g. marko/admin-auth) binds + // RememberTokenStorageInterface; without one, session guards fall back to the user + // provider's single remember_token column. + AuthManager::class => function (ContainerInterface $container): AuthManager { + return new AuthManager( + config: $container->get(AuthConfig::class), + session: $container->get(SessionInterface::class), + providerResolver: $container->get(UserProviderResolver::class), + eventDispatcher: $container->get(EventDispatcherInterface::class), + cookieJar: $container->get(CookieJarInterface::class), + rememberTokenManager: $container->get(RememberTokenManager::class), + guardDriverRegistry: $container->get(GuardDriverRegistry::class), + loginThrottle: $container->get(LoginThrottleInterface::class), + rememberTokenStorage: $container->has(RememberTokenStorageInterface::class) + ? $container->get(RememberTokenStorageInterface::class) + : null, + currentRequest: $container->get(CurrentRequest::class), + ); + }, GuardInterface::class => function (ContainerInterface $container): GuardInterface { return $container->get(AuthManager::class)->guard(); }, diff --git a/packages/authentication/src/AuthManager.php b/packages/authentication/src/AuthManager.php index 0d2cdc69e..8a4dd0899 100644 --- a/packages/authentication/src/AuthManager.php +++ b/packages/authentication/src/AuthManager.php @@ -8,10 +8,12 @@ use Marko\Authentication\Contracts\CookieJarInterface; use Marko\Authentication\Contracts\GuardInterface; use Marko\Authentication\Contracts\LoginThrottleInterface; +use Marko\Authentication\Contracts\RememberTokenStorageInterface; use Marko\Authentication\Contracts\UserProviderInterface; use Marko\Authentication\Exceptions\AuthException; use Marko\Authentication\Guard\GuardDriverRegistry; use Marko\Authentication\Guard\SessionGuard; +use Marko\Authentication\Http\CurrentRequest; use Marko\Authentication\Token\RememberTokenManager; use Marko\Config\Exceptions\ConfigNotFoundException; use Marko\Core\Contracts\ResettableInterface; @@ -41,6 +43,8 @@ public function __construct( private readonly RememberTokenManager $rememberTokenManager, private readonly GuardDriverRegistry $guardDriverRegistry = new GuardDriverRegistry(), private readonly ?LoginThrottleInterface $loginThrottle = null, + private readonly ?RememberTokenStorageInterface $rememberTokenStorage = null, + private readonly ?CurrentRequest $currentRequest = null, ) {} /** @@ -145,6 +149,8 @@ private function createSessionGuard( eventDispatcher: $this->eventDispatcher, rememberCookiePrefix: $this->config->rememberCookiePrefix(), loginThrottle: $this->loginThrottle, + rememberTokenStorage: $this->rememberTokenStorage, + currentRequest: $this->currentRequest, ); } diff --git a/packages/authentication/src/Contracts/RememberTokenStorageInterface.php b/packages/authentication/src/Contracts/RememberTokenStorageInterface.php index ee7ff0a24..022fdcee7 100644 --- a/packages/authentication/src/Contracts/RememberTokenStorageInterface.php +++ b/packages/authentication/src/Contracts/RememberTokenStorageInterface.php @@ -4,8 +4,57 @@ namespace Marko\Authentication\Contracts; +use Marko\Authentication\Token\RememberTokenRecord; + +/** + * Per-device remember-me tokens (a remember_tokens table or equivalent). + * + * When an implementation is bound, SessionGuard issues one token per device + * instead of the single remember_token column on the user, so a remember-me + * login on one device no longer logs another out. marko/admin-auth binds a + * database implementation; without a binding SessionGuard falls back to the + * user provider's single-column token. + */ interface RememberTokenStorageInterface { + /** + * Store a newly issued device token. + */ + public function store( + RememberTokenRecord $token, + ): void; + + /** + * The token the guard issued with this selector, or null when there is none. + */ + public function findBySelector( + string $guard, + string $selector, + ): ?RememberTokenRecord; + + /** + * Replace a token's validator hash, keeping its selector and expiry. + * + * Only replaces it while the stored hash still equals $currentValidatorHash, + * so of two requests rotating the same token at once exactly one succeeds. + * + * @return bool Whether the token was rotated + */ + public function rotateValidator( + string $guard, + string $selector, + string $currentValidatorHash, + string $newValidatorHash, + ): bool; + + /** + * Delete one device's token (logout, expiry, a deleted user). + */ + public function deleteBySelector( + string $guard, + string $selector, + ): void; + /** * Clear all expired remember tokens from storage. * diff --git a/packages/authentication/src/Guard/SessionGuard.php b/packages/authentication/src/Guard/SessionGuard.php index eeb2b039d..f8ce91f73 100644 --- a/packages/authentication/src/Guard/SessionGuard.php +++ b/packages/authentication/src/Guard/SessionGuard.php @@ -10,13 +10,16 @@ use Marko\Authentication\Contracts\CookieJarInterface; use Marko\Authentication\Contracts\GuardInterface; use Marko\Authentication\Contracts\LoginThrottleInterface; +use Marko\Authentication\Contracts\RememberTokenStorageInterface; use Marko\Authentication\Contracts\UserProviderInterface; use Marko\Authentication\Event\FailedLoginEvent; use Marko\Authentication\Event\LoginEvent; use Marko\Authentication\Event\LogoutEvent; use Marko\Authentication\Exceptions\AuthException; use Marko\Authentication\Exceptions\TooManyLoginAttemptsException; +use Marko\Authentication\Http\CurrentRequest; use Marko\Authentication\Token\RememberTokenManager; +use Marko\Authentication\Token\RememberTokenRecord; use Marko\Core\Contracts\ResettableInterface; use Marko\Core\Event\EventDispatcherInterface; use Marko\Session\Contracts\SessionInterface; @@ -40,6 +43,8 @@ public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly string $rememberCookiePrefix = 'remember_', private readonly ?LoginThrottleInterface $loginThrottle = null, + private readonly ?RememberTokenStorageInterface $rememberTokenStorage = null, + private readonly ?CurrentRequest $currentRequest = null, ) {} /** @@ -89,6 +94,11 @@ public function user(): ?AuthenticatableInterface * requests authenticate from the session and the cookie is not consumed * again until that session ends. * + * With a RememberTokenStorageInterface bound, the cookie is a per-device + * "selector:validator" token. Without one (or for a cookie issued before + * the storage was installed) it is "id|token", checked against the user's + * single remember_token column. + * * @throws AuthException|DateMalformedStringException|RandomException */ private function authenticateViaRememberCookie(): ?AuthenticatableInterface @@ -103,16 +113,36 @@ private function authenticateViaRememberCookie(): ?AuthenticatableInterface return null; } - $parts = explode('|', $cookieValue, 2); + if (str_contains($cookieValue, '|')) { + return $this->authenticateViaUserToken($cookieValue, $this->cookieJar, $this->tokenManager); + } - if (count($parts) !== 2) { - return null; + if ($this->rememberTokenStorage !== null) { + return $this->authenticateViaDeviceToken( + $cookieValue, + $this->cookieJar, + $this->tokenManager, + $this->rememberTokenStorage, + ); } - [$id, $token] = $parts; + return null; + } + + /** + * The single-column path: an "id|token" cookie checked against the user's remember_token. + * + * @throws AuthException|DateMalformedStringException|RandomException + */ + private function authenticateViaUserToken( + string $cookieValue, + CookieJarInterface $cookieJar, + RememberTokenManager $tokenManager, + ): ?AuthenticatableInterface { + [$id, $token] = explode('|', $cookieValue, 2); // Providers store (and compare against) the hash, never the plain token - $user = $this->provider->retrieveByRememberToken($id, $this->tokenManager->hash($token)); + $user = $this->provider->retrieveByRememberToken($id, $tokenManager->hash($token)); if ($user === null) { return null; @@ -121,22 +151,28 @@ private function authenticateViaRememberCookie(): ?AuthenticatableInterface // Validate the token $storedHash = $user->getRememberToken(); - if ($storedHash === null || !$this->tokenManager->validate($token, $storedHash)) { + if ($storedHash === null || !$tokenManager->validate($token, $storedHash)) { return null; } // The expiry lives server-side: the cookie's own Expires is client-controlled $expiresAt = $user->getRememberTokenExpiresAt(); - if ($expiresAt === null || $this->tokenManager->hasExpired($expiresAt)) { + if ($expiresAt === null || $tokenManager->hasExpired($expiresAt)) { $this->provider->updateRememberToken($user, null, null); - $this->cookieJar->delete($this->getRememberCookieName()); + $cookieJar->delete($this->getRememberCookieName()); return null; } $this->startSession($user); + if ($this->rememberTokenStorage !== null) { + // Per-device storage is now installed: retire the column token, and the + // rotation below moves this device onto its own token + $this->provider->updateRememberToken($user, null, null); + } + // Rotate the token (prevents replay) but keep the original expiry, so a // remember-me login has a fixed lifetime however often it is used $this->createRememberToken($user, $expiresAt); @@ -146,6 +182,84 @@ private function authenticateViaRememberCookie(): ?AuthenticatableInterface return $user; } + /** + * The per-device path: a "selector:validator" cookie checked against this device's stored token. + * + * @throws AuthException|RandomException + */ + private function authenticateViaDeviceToken( + string $cookieValue, + CookieJarInterface $cookieJar, + RememberTokenManager $tokenManager, + RememberTokenStorageInterface $rememberTokenStorage, + ): ?AuthenticatableInterface { + $parts = explode(':', $cookieValue, 2); + + if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') { + return null; + } + + [$selector, $validator] = $parts; + $record = $rememberTokenStorage->findBySelector($this->name, $selector); + + if ($record === null) { + // Revoked, purged or never issued: this cookie can never log in again + $cookieJar->delete($this->getRememberCookieName()); + + return null; + } + + // A wrong validator is a tampered cookie, or a request that lost a rotation + // race to another request from the same device: reject it, keep the token + if (!$tokenManager->validate($validator, $record->validatorHash)) { + return null; + } + + // The expiry lives server-side: the cookie's own Expires is client-controlled + if ($tokenManager->hasExpired($record->expiresAt)) { + $rememberTokenStorage->deleteBySelector($this->name, $selector); + $cookieJar->delete($this->getRememberCookieName()); + + return null; + } + + $user = $this->provider->retrieveById($record->userId); + + if ($user === null) { + $rememberTokenStorage->deleteBySelector($this->name, $selector); + $cookieJar->delete($this->getRememberCookieName()); + + return null; + } + + // Rotate only the validator (prevents replay). The selector and expiry are + // kept, so a remember-me login has a fixed lifetime however often it is used + $newValidator = $tokenManager->generate(); + $rotated = $rememberTokenStorage->rotateValidator( + $this->name, + $selector, + $record->validatorHash, + $tokenManager->hash($newValidator), + ); + + if (!$rotated) { + // Another request from this device rotated it first and logs it in + return null; + } + + $this->startSession($user); + + $cookieJar->set( + $this->getRememberCookieName(), + $selector . ':' . $newValidator, + $tokenManager->minutesUntil($record->expiresAt), + ); + + $this->dispatchLoginEvent($user, true); + + return $user; + } + /** * @throws AuthException|DateMalformedStringException|RandomException */ @@ -260,10 +374,58 @@ private function createRememberToken( throw AuthException::rememberMeUnavailable($this->name); } - $token = $this->tokenManager->generate(); - $hashedToken = $this->tokenManager->hash($token); $expiresAt ??= $this->tokenManager->expiresAt(); + $cookieValue = $this->rememberTokenStorage !== null + ? $this->storeDeviceToken($user, $expiresAt, $this->tokenManager, $this->rememberTokenStorage) + : $this->storeUserToken($user, $expiresAt, $this->tokenManager); + + $this->cookieJar->set( + $this->getRememberCookieName(), + $cookieValue, + $this->tokenManager->minutesUntil($expiresAt), + ); + } + + /** + * Store a token for this device only, returning the "selector:validator" cookie value. + * + * @throws RandomException + */ + private function storeDeviceToken( + AuthenticatableInterface $user, + DateTimeImmutable $expiresAt, + RememberTokenManager $tokenManager, + RememberTokenStorageInterface $rememberTokenStorage, + ): string { + $selector = $tokenManager->generateSelector(); + $validator = $tokenManager->generate(); + + $rememberTokenStorage->store(new RememberTokenRecord( + guard: $this->name, + userId: $user->getAuthIdentifier(), + selector: $selector, + validatorHash: $tokenManager->hash($validator), + expiresAt: $expiresAt, + userAgent: $this->currentUserAgent(), + )); + + return $selector . ':' . $validator; + } + + /** + * Store the token in the user's single remember_token column, returning the "id|token" cookie value. + * + * @throws AuthException|RandomException + */ + private function storeUserToken( + AuthenticatableInterface $user, + DateTimeImmutable $expiresAt, + RememberTokenManager $tokenManager, + ): string { + $token = $tokenManager->generate(); + $hashedToken = $tokenManager->hash($token); + $this->provider->updateRememberToken($user, $hashedToken, $expiresAt); if ( @@ -273,12 +435,17 @@ private function createRememberToken( throw AuthException::rememberTokenNotStored($this->name, $this->provider::class); } - $cookieValue = $user->getAuthIdentifier() . '|' . $token; - $this->cookieJar->set( - $this->getRememberCookieName(), - $cookieValue, - $this->tokenManager->minutesUntil($expiresAt), - ); + return $user->getAuthIdentifier() . '|' . $token; + } + + /** + * The requesting browser's User-Agent, stored with a device token to tell a user's devices apart. + */ + private function currentUserAgent(): ?string + { + $userAgent = $this->currentRequest?->get()?->header('User-Agent'); + + return $userAgent === null || $userAgent === '' ? null : mb_substr($userAgent, 0, 255); } private function getSessionKey(): string @@ -330,8 +497,7 @@ public function logout(): void $user = $this->user(); if ($user !== null && $this->cookieJar !== null && $this->tokenManager !== null) { - $this->provider->updateRememberToken($user, null, null); - $this->cookieJar->delete($this->getRememberCookieName()); + $this->forgetRememberToken($user, $this->cookieJar); } $this->session->remove($this->getSessionKey()); @@ -345,6 +511,43 @@ public function logout(): void } } + /** + * Revoke this device's remember token and delete its cookie. With per-device + * storage the user's other devices stay remembered. + */ + private function forgetRememberToken( + AuthenticatableInterface $user, + CookieJarInterface $cookieJar, + ): void { + if ($this->rememberTokenStorage === null) { + $this->provider->updateRememberToken($user, null, null); + } else { + $this->deleteDeviceToken($user, $cookieJar, $this->rememberTokenStorage); + } + + $cookieJar->delete($this->getRememberCookieName()); + } + + private function deleteDeviceToken( + AuthenticatableInterface $user, + CookieJarInterface $cookieJar, + RememberTokenStorageInterface $rememberTokenStorage, + ): void { + $cookieValue = $cookieJar->get($this->getRememberCookieName()); + + if ($cookieValue === null || str_contains($cookieValue, '|') || !str_contains($cookieValue, ':')) { + return; + } + + $selector = explode(':', $cookieValue, 2)[0]; + $record = $rememberTokenStorage->findBySelector($this->name, $selector); + + // Only the logged-in user's own token: a planted cookie cannot revoke another user's device + if ($record !== null && (string) $record->userId === (string) $user->getAuthIdentifier()) { + $rememberTokenStorage->deleteBySelector($this->name, $selector); + } + } + private function dispatchLogoutEvent( AuthenticatableInterface $user, ): void { diff --git a/packages/authentication/src/Token/RememberTokenManager.php b/packages/authentication/src/Token/RememberTokenManager.php index 6c95e14f1..a2f77a7d8 100644 --- a/packages/authentication/src/Token/RememberTokenManager.php +++ b/packages/authentication/src/Token/RememberTokenManager.php @@ -33,6 +33,16 @@ public function generate(): string return bin2hex(random_bytes(32)); } + /** + * A random, non-secret lookup key for a per-device token (the part before the colon in the cookie). + * + * @throws RandomException + */ + public function generateSelector(): string + { + return bin2hex(random_bytes(16)); + } + public function hash( string $token, ): string { diff --git a/packages/authentication/src/Token/RememberTokenRecord.php b/packages/authentication/src/Token/RememberTokenRecord.php new file mode 100644 index 000000000..fafead03d --- /dev/null +++ b/packages/authentication/src/Token/RememberTokenRecord.php @@ -0,0 +1,27 @@ + */ + public array $tokens = []; + + public function __construct( + private readonly ClockInterface $clock, + ) {} + + public function store( + RememberTokenRecord $token, + ): void { + $this->tokens[$token->guard . ':' . $token->selector] = $token; + } + + public function findBySelector( + string $guard, + string $selector, + ): ?RememberTokenRecord { + return $this->tokens["$guard:$selector"] ?? null; + } + + public function rotateValidator( + string $guard, + string $selector, + string $currentValidatorHash, + string $newValidatorHash, + ): bool { + $token = $this->tokens["$guard:$selector"] ?? null; + + if ($token === null || $token->validatorHash !== $currentValidatorHash) { + return false; + } + + $this->tokens["$guard:$selector"] = new RememberTokenRecord( + guard: $token->guard, + userId: $token->userId, + selector: $token->selector, + validatorHash: $newValidatorHash, + expiresAt: $token->expiresAt, + userAgent: $token->userAgent, + ); + + return true; + } + + public function deleteBySelector( + string $guard, + string $selector, + ): void { + unset($this->tokens["$guard:$selector"]); + } + + public function clearExpiredTokens(): int + { + $now = $this->clock->now(); + $live = array_filter($this->tokens, fn (RememberTokenRecord $token): bool => $token->expiresAt > $now); + $cleared = count($this->tokens) - count($live); + $this->tokens = $live; + + return $cleared; + } + + public function clearAllTokens(): int + { + $cleared = count($this->tokens); + $this->tokens = []; + + return $cleared; + } + + /** + * @return list The tokens stored for one user on a guard + */ + public function tokensFor( + string $guard, + int|string $userId, + ): array { + return array_values(array_filter( + $this->tokens, + fn (RememberTokenRecord $token): bool => $token->guard === $guard + && (string) $token->userId === (string) $userId, + )); + } +} diff --git a/packages/authentication/tests/Integration/RememberMeIntegrationTest.php b/packages/authentication/tests/Integration/RememberMeIntegrationTest.php index 876730b04..d2bbf0512 100644 --- a/packages/authentication/tests/Integration/RememberMeIntegrationTest.php +++ b/packages/authentication/tests/Integration/RememberMeIntegrationTest.php @@ -8,6 +8,7 @@ use Marko\Authentication\AuthManager; use Marko\Authentication\Contracts\CookieJarInterface; use Marko\Authentication\Contracts\GuardInterface; +use Marko\Authentication\Contracts\RememberTokenStorageInterface; use Marko\Authentication\Contracts\UserProviderInterface; use Marko\Authentication\Cookie\RequestCookieJar; use Marko\Authentication\Event\FailedLoginEvent; @@ -15,7 +16,9 @@ use Marko\Authentication\Event\LogoutEvent; use Marko\Authentication\Exceptions\AuthException; use Marko\Authentication\Guard\SessionGuard; +use Marko\Authentication\Http\CurrentRequest; use Marko\Authentication\Middleware\QueuedCookiesMiddleware; +use Marko\Authentication\Tests\Fixtures\InMemoryRememberTokenStorage; use Marko\Authentication\Token\RememberTokenManager; use Marko\Cache\Config\CacheConfig; use Marko\Cache\Contracts\CacheInterface; @@ -169,6 +172,27 @@ function (): void { }, ); + it('gives session guards the bound per-device remember token storage', function (): void { + $container = bootAuthContainer(new FakeUserProvider()); + $storage = new InMemoryRememberTokenStorage(new FakeClock()); + $container->instance(RememberTokenStorageInterface::class, $storage); + + $guard = $container->get(GuardInterface::class); + + expect(new ReflectionProperty(SessionGuard::class, 'rememberTokenStorage')->getValue($guard)) + ->toBe($storage) + ->and(new ReflectionProperty(SessionGuard::class, 'currentRequest')->getValue($guard)) + ->toBe($container->get(CurrentRequest::class)); + }); + + it('falls back to the single-column remember token when no storage is bound', function (): void { + $container = bootAuthContainer(new FakeUserProvider()); + + $guard = $container->get(GuardInterface::class); + + expect(new ReflectionProperty(SessionGuard::class, 'rememberTokenStorage')->getValue($guard))->toBeNull(); + }); + it('shares one RequestCookieJar between the guard and the middleware', function (): void { $container = bootAuthContainer(new FakeUserProvider()); diff --git a/packages/authentication/tests/Unit/Command/ClearTokensCommandTest.php b/packages/authentication/tests/Unit/Command/ClearTokensCommandTest.php index d14c9f64c..7bff9392e 100644 --- a/packages/authentication/tests/Unit/Command/ClearTokensCommandTest.php +++ b/packages/authentication/tests/Unit/Command/ClearTokensCommandTest.php @@ -3,10 +3,12 @@ declare(strict_types=1); use Marko\Authentication\Command\ClearTokensCommand; -use Marko\Authentication\Contracts\RememberTokenStorageInterface; +use Marko\Authentication\Tests\Fixtures\InMemoryRememberTokenStorage; +use Marko\Authentication\Token\RememberTokenRecord; use Marko\Core\Attributes\Command; use Marko\Core\Command\Input; use Marko\Core\Command\Output; +use Marko\Testing\Fake\FakeClock; it('has correct command name auth:clear-tokens', function (): void { $reflection = new ReflectionClass(ClearTokensCommand::class); @@ -32,130 +34,71 @@ expect($command->flags)->toBe(['force']); }); -it('clears expired tokens', function (): void { - $storage = new class () implements RememberTokenStorageInterface - { - public int $expiredCleared = 0; - - public int $allCleared = 0; - - public function clearExpiredTokens(): int - { - $this->expiredCleared = 3; - - return $this->expiredCleared; - } - - public function clearAllTokens(): int - { - $this->allCleared = 5; - - return $this->allCleared; - } - }; - - $command = new ClearTokensCommand($storage); - $input = new Input(['marko', 'auth:clear-tokens']); +/** + * Storage holding $expired expired and $live unexpired tokens. + */ +function storageWithTokens( + int $expired, + int $live, +): InMemoryRememberTokenStorage { + $clock = new FakeClock('2026-01-01 12:00:00'); + $storage = new InMemoryRememberTokenStorage($clock); + + foreach (range(1, $expired + $live) as $i) { + $storage->store(new RememberTokenRecord( + guard: 'session', + userId: $i, + selector: "selector-$i", + validatorHash: hash('sha256', "validator-$i"), + expiresAt: $clock->now()->modify($i <= $expired ? '-1 minute' : '+1 day'), + )); + } + + return $storage; +} + +/** + * @return array{int, string} The exit code and what the command wrote + */ +function runClearTokens( + ClearTokensCommand $command, + string ...$arguments, +): array { $stream = fopen('php://memory', 'r+'); - $output = new Output($stream); - - $result = $command->execute($input, $output); - - expect($result)->toBe(0) - ->and($storage->expiredCleared)->toBe(3); -}); + $result = $command->execute(new Input(['marko', 'auth:clear-tokens', ...$arguments]), new Output($stream)); + rewind($stream); -it('reports number of tokens cleared', function (): void { - $storage = new class () implements RememberTokenStorageInterface - { - public function clearExpiredTokens(): int - { - return 5; - } - - public function clearAllTokens(): int - { - return 10; - } - }; - - $command = new ClearTokensCommand($storage); - $input = new Input(['marko', 'auth:clear-tokens']); - $stream = fopen('php://memory', 'r+'); - $output = new Output($stream); + return [$result, (string) stream_get_contents($stream)]; +} - $command->execute($input, $output); +it('clears expired tokens and keeps unexpired ones', function (): void { + $storage = storageWithTokens(expired: 3, live: 2); - rewind($stream); - $content = stream_get_contents($stream); + [$result, $content] = runClearTokens(new ClearTokensCommand($storage)); - expect($content)->toContain('5') - ->and($content)->toContain('token'); + expect($result)->toBe(0) + ->and($storage->tokens)->toHaveCount(2) + ->and($storage->findBySelector('session', 'selector-4'))->not->toBeNull() + ->and($storage->findBySelector('session', 'selector-1'))->toBeNull() + ->and($content)->toContain('Cleared 3 expired token(s).'); }); it('handles no expired tokens gracefully', function (): void { - $storage = new class () implements RememberTokenStorageInterface - { - public function clearExpiredTokens(): int - { - return 0; - } - - public function clearAllTokens(): int - { - return 0; - } - }; - - $command = new ClearTokensCommand($storage); - $input = new Input(['marko', 'auth:clear-tokens']); - $stream = fopen('php://memory', 'r+'); - $output = new Output($stream); + $storage = storageWithTokens(expired: 0, live: 2); - $result = $command->execute($input, $output); - - rewind($stream); - $content = stream_get_contents($stream); + [$result, $content] = runClearTokens(new ClearTokensCommand($storage)); expect($result)->toBe(0) + ->and($storage->tokens)->toHaveCount(2) ->and($content)->toContain('No expired tokens'); }); it('supports --force flag for all tokens', function (): void { - $storage = new class () implements RememberTokenStorageInterface - { - public bool $clearAllCalled = false; - - public bool $clearExpiredCalled = false; - - public function clearExpiredTokens(): int - { - $this->clearExpiredCalled = true; - - return 2; - } + $storage = storageWithTokens(expired: 2, live: 8); - public function clearAllTokens(): int - { - $this->clearAllCalled = true; - - return 10; - } - }; - - $command = new ClearTokensCommand($storage); - $input = new Input(['marko', 'auth:clear-tokens', '--force']); - $stream = fopen('php://memory', 'r+'); - $output = new Output($stream); - - $result = $command->execute($input, $output); - - rewind($stream); - $content = stream_get_contents($stream); + [$result, $content] = runClearTokens(new ClearTokensCommand($storage), '--force'); expect($result)->toBe(0) - ->and($storage->clearAllCalled)->toBeTrue() - ->and($storage->clearExpiredCalled)->toBeFalse() - ->and($content)->toContain('10') - ->and($content)->toContain('all'); + ->and($storage->tokens)->toBe([]) + ->and($content)->toContain('Cleared all 10 token(s).'); }); diff --git a/packages/authentication/tests/Unit/Guard/SessionGuardDeviceRememberTest.php b/packages/authentication/tests/Unit/Guard/SessionGuardDeviceRememberTest.php new file mode 100644 index 000000000..5770d525d --- /dev/null +++ b/packages/authentication/tests/Unit/Guard/SessionGuardDeviceRememberTest.php @@ -0,0 +1,321 @@ +clock = new FakeClock('2026-01-01 12:00:00'); + $this->tokenManager = new RememberTokenManager($this->clock, lifetimeMinutes: 60); + $this->storage = new InMemoryRememberTokenStorage($this->clock); + $this->user = new FakeAuthenticatable(id: 42); + $this->provider = new FakeUserProvider([42 => $this->user]); + } + + /** + * A guard for a fresh request from the device whose cookies live in $cookieJar (a new session each time). + */ + public function deviceGuard( + FakeCookieJar $cookieJar, + ?FakeEventDispatcher $eventDispatcher = null, + ?CurrentRequest $currentRequest = null, + ): SessionGuard { + $session = new FakeSession(); + $session->start(); + + return new SessionGuard( + session: $session, + provider: $this->provider, + name: 'web', + cookieJar: $cookieJar, + tokenManager: $this->tokenManager, + eventDispatcher: $eventDispatcher, + rememberTokenStorage: $this->storage, + currentRequest: $currentRequest, + ); + } + + /** + * Log the user in with remember-me on a new device and return that device's cookie jar. + */ + public function rememberedDevice(): FakeCookieJar + { + $cookieJar = new FakeCookieJar(); + $this->deviceGuard($cookieJar)->login($this->user, remember: true); + + return $cookieJar; + } +} + +function cookieSelector( + FakeCookieJar $cookieJar, +): string { + return explode(':', (string) $cookieJar->get('remember_web'))[0]; +} + +describe('SessionGuard per-device remember tokens', function (): void { + it('stores a hashed selector:validator token for the device and leaves the user column alone', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + + [$selector, $validator] = explode(':', (string) $cookieJar->get('remember_web')); + $record = $world->storage->findBySelector('web', $selector); + + expect($record)->toBeInstanceOf(RememberTokenRecord::class) + ->and($record->userId)->toBe(42) + ->and($record->validatorHash)->toBe($world->tokenManager->hash($validator)) + ->and($record->validatorHash)->not->toBe($validator) + ->and($record->expiresAt)->toEqual($world->clock->now()->modify('+60 minutes')) + ->and($world->provider->lastRememberTokenUpdate)->toBeNull() + ->and($world->user->getRememberToken())->toBeNull(); + }); + + it('keeps two devices logged in independently', function (): void { + $world = new DeviceRememberWorld(); + $laptop = $world->rememberedDevice(); + $phone = $world->rememberedDevice(); + + // Each device comes back several times; every remember-me login rotates only its own token + foreach (range(1, 3) as $visit) { + expect($world->deviceGuard($laptop)->user())->toBe($world->user) + ->and($world->deviceGuard($phone)->user())->toBe($world->user); + } + + expect($world->storage->tokensFor('web', 42))->toHaveCount(2); + }); + + it('does not log one device out when another device logs in with remember-me', function (): void { + $world = new DeviceRememberWorld(); + $laptop = $world->rememberedDevice(); + + $world->rememberedDevice(); + $world->rememberedDevice(); + + expect($world->deviceGuard($laptop)->user())->toBe($world->user); + }); + + it('rotates only the validator, keeping the selector and the original expiry', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $original = (string) $cookieJar->get('remember_web'); + $selector = cookieSelector($cookieJar); + $expiresAt = $world->storage->findBySelector('web', $selector)->expiresAt; + + $world->clock->travel('+20 minutes'); + $world->deviceGuard($cookieJar)->user(); + + expect($cookieJar->get('remember_web'))->not->toBe($original) + ->and(cookieSelector($cookieJar))->toBe($selector) + ->and($world->storage->findBySelector('web', $selector)->expiresAt)->toEqual($expiresAt) + ->and($world->storage->tokensFor('web', 42))->toHaveCount(1); + }); + + it('rejects a replayed validator once the token has rotated', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $stolen = new FakeCookieJar(); + $stolen->set('remember_web', (string) $cookieJar->get('remember_web')); + + $world->deviceGuard($cookieJar)->user(); + + expect($world->deviceGuard($stolen)->user())->toBeNull(); + }); + + it('logs one device out without affecting the other', function (): void { + $world = new DeviceRememberWorld(); + $laptop = $world->rememberedDevice(); + $phone = $world->rememberedDevice(); + $laptopSelector = cookieSelector($laptop); + + $world->deviceGuard($laptop)->logout(); + + expect($laptop->get('remember_web'))->toBeNull() + ->and($world->storage->findBySelector('web', $laptopSelector))->toBeNull() + ->and($world->storage->tokensFor('web', 42))->toHaveCount(1) + ->and($world->deviceGuard($phone)->user())->toBe($world->user); + }); + + it('does not let a planted cookie revoke another user\'s device on logout', function (): void { + $world = new DeviceRememberWorld(); + $victim = $world->rememberedDevice(); + $intruder = new FakeAuthenticatable(id: 7); + $world->storage->store(new RememberTokenRecord( + guard: 'web', + userId: 7, + selector: 'intruder-selector', + validatorHash: $world->tokenManager->hash('intruder-validator'), + expiresAt: $world->clock->now()->modify('+1 hour'), + )); + $intruderJar = new FakeCookieJar(); + $guard = $world->deviceGuard($intruderJar); + $guard->login($intruder); + $intruderJar->set('remember_web', cookieSelector($victim) . ':anything'); + + $guard->logout(); + + expect($world->storage->findBySelector('web', cookieSelector($victim)))->not->toBeNull(); + }); + + it('rejects a tampered validator and keeps the device token', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $selector = cookieSelector($cookieJar); + $cookieJar->set('remember_web', $selector . ':' . str_repeat('0', 64)); + + expect($world->deviceGuard($cookieJar)->user())->toBeNull() + ->and($world->storage->findBySelector('web', $selector))->not->toBeNull(); + }); + + it('rejects malformed cookies', function (string $cookie): void { + $world = new DeviceRememberWorld(); + $world->rememberedDevice(); + $cookieJar = new FakeCookieJar(); + $cookieJar->set('remember_web', $cookie); + + expect($world->deviceGuard($cookieJar)->user())->toBeNull(); + })->with([ + 'no separator' => ['abcdef'], + 'empty selector' => [':validator'], + 'empty validator' => ['selector:'], + ]); + + it('forgets the cookie of a token that no longer exists', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $world->storage->clearAllTokens(); + + expect($world->deviceGuard($cookieJar)->user())->toBeNull() + ->and($cookieJar->get('remember_web'))->toBeNull(); + }); + + it('rejects an expired device token, deleting its row and cookie', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $selector = cookieSelector($cookieJar); + + $world->clock->travel('+60 minutes'); + + expect($world->deviceGuard($cookieJar)->user())->toBeNull() + ->and($world->storage->findBySelector('web', $selector))->toBeNull() + ->and($cookieJar->get('remember_web'))->toBeNull(); + }); + + it('deletes the token of a user who no longer exists', function (): void { + $world = new DeviceRememberWorld(); + $world->storage->store(new RememberTokenRecord( + guard: 'web', + userId: 99, + selector: 'gone-selector', + validatorHash: $world->tokenManager->hash('gone-validator'), + expiresAt: $world->clock->now()->modify('+1 hour'), + )); + $cookieJar = new FakeCookieJar(); + $cookieJar->set('remember_web', 'gone-selector:gone-validator'); + + expect($world->deviceGuard($cookieJar)->user())->toBeNull() + ->and($world->storage->findBySelector('web', 'gone-selector'))->toBeNull() + ->and($cookieJar->get('remember_web'))->toBeNull(); + }); + + it('logs in only one of two requests racing to rotate the same token', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $cookie = (string) $cookieJar->get('remember_web'); + $first = new FakeCookieJar(); + $first->set('remember_web', $cookie); + $second = new FakeCookieJar(); + $second->set('remember_web', $cookie); + $firstGuard = $world->deviceGuard($first); + $secondGuard = $world->deviceGuard($second); + + expect($firstGuard->user())->toBe($world->user) + ->and($secondGuard->user())->toBeNull() + ->and($world->storage->tokensFor('web', 42))->toHaveCount(1); + }); + + it('only accepts tokens the same guard issued', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $record = $world->storage->findBySelector('web', cookieSelector($cookieJar)); + $world->storage->clearAllTokens(); + $world->storage->store(new RememberTokenRecord( + guard: 'admin', + userId: $record->userId, + selector: $record->selector, + validatorHash: $record->validatorHash, + expiresAt: $record->expiresAt, + )); + + expect($world->deviceGuard($cookieJar)->user())->toBeNull(); + }); + + it('starts a session and dispatches a remembered LoginEvent on a device-token login', function (): void { + $world = new DeviceRememberWorld(); + $cookieJar = $world->rememberedDevice(); + $events = new FakeEventDispatcher(); + + $world->deviceGuard($cookieJar, $events)->user(); + + $loginEvents = $events->dispatched(LoginEvent::class); + + expect($loginEvents)->toHaveCount(1) + ->and($loginEvents[0]->remember)->toBeTrue(); + }); + + it('records the device user agent with the token', function (): void { + $world = new DeviceRememberWorld(); + $currentRequest = new CurrentRequest(); + $currentRequest->set(new Request(server: ['HTTP_USER_AGENT' => 'Firefox on Linux'])); + $cookieJar = new FakeCookieJar(); + + $world->deviceGuard($cookieJar, currentRequest: $currentRequest)->login($world->user, remember: true); + + expect($world->storage->findBySelector('web', cookieSelector($cookieJar))->userAgent) + ->toBe('Firefox on Linux'); + }); + + it('moves a single-column id|token cookie onto its own device token', function (): void { + $world = new DeviceRememberWorld(); + $world->user->setRememberToken($world->tokenManager->hash('legacy-token')); + $world->user->setRememberTokenExpiresAt($world->clock->now()->modify('+30 minutes')); + $cookieJar = new FakeCookieJar(); + $cookieJar->set('remember_web', '42|legacy-token'); + + expect($world->deviceGuard($cookieJar)->user())->toBe($world->user) + ->and($world->user->getRememberToken())->toBeNull() + ->and($cookieJar->get('remember_web'))->toContain(':') + ->and($world->storage->findBySelector('web', cookieSelector($cookieJar))->expiresAt) + ->toEqual($world->clock->now()->modify('+30 minutes')) + ->and($world->deviceGuard($cookieJar)->user())->toBe($world->user); + }); +}); diff --git a/packages/authentication/tests/Unit/Token/RememberTokenManagerTest.php b/packages/authentication/tests/Unit/Token/RememberTokenManagerTest.php index 57fba61ef..c079bdef6 100644 --- a/packages/authentication/tests/Unit/Token/RememberTokenManagerTest.php +++ b/packages/authentication/tests/Unit/Token/RememberTokenManagerTest.php @@ -28,6 +28,16 @@ expect(count(array_unique($tokens)))->toBe(100); }); +it('generates unique hex selectors with no cookie separator in them', function () { + $manager = new RememberTokenManager(new FakeClock()); + + $selectors = array_map(fn (): string => $manager->generateSelector(), range(1, 100)); + $isHex = fn (string $selector): bool => strlen($selector) === 32 && ctype_xdigit($selector); + + expect(count(array_unique($selectors)))->toBe(100) + ->and(array_all($selectors, $isHex))->toBeTrue(); +}); + it('hashes token for storage', function () { $manager = new RememberTokenManager(new FakeClock()); diff --git a/packages/docs-markdown/docs/packages/admin-auth.md b/packages/docs-markdown/docs/packages/admin-auth.md index 5452363e7..543340700 100644 --- a/packages/docs-markdown/docs/packages/admin-auth.md +++ b/packages/docs-markdown/docs/packages/admin-auth.md @@ -26,9 +26,16 @@ The package ships no migration files. Its tables come from its entities, which ` | `role_permissions` | `RolePermission` | Role to permission assignments (unique `role_id`, `permission_id`; index on `permission_id`) | | `admin_users` | `AdminUser` | Admin users (unique `email`), with the remember-me token hash and its `remember_token_expires_at` | | `admin_user_roles` | `AdminUserRole` | User to role assignments (unique `user_id`, `role_id`; index on `role_id`) | +| `remember_tokens` | `RememberToken` | One remember-me token per device: guard, user ID, unique `selector`, `validator_hash`, `expires_at` (indexed), `user_agent` | Both pivots cascade: deleting a role, permission or admin user deletes its assignment rows. +### Remember-Me Tokens + +The module binds `RememberTokenStorageInterface` to `RememberTokenRepository`, so remember-me tokens live in the `remember_tokens` table, one row per device. Logging in with "remember me" on a second browser no longer logs the first one out, and logging out deletes only that browser's row. The binding covers every session guard in the app, not just `admin`; the `guard` column keeps each guard's tokens apart. See [Per-Device Tokens](/docs/packages/authentication/#per-device-tokens) for how the cookie is checked and rotated. + +Run `marko db:migrate` after upgrading to create the table, before deploying the new code: remember-me logins write to it. Admins remembered through the old `admin_users.remember_token` column stay signed in; their next visit moves them onto a device row and clears the column. Schedule `marko auth:clear-tokens` to purge rows that expired on devices that never came back. + :::note `admin_users.remember_token_expires_at` holds when an admin's remember-me token stops being accepted; the expiry is enforced server-side rather than trusted from the cookie. Run `marko db:migrate` after upgrading to add the column. Remember tokens issued before the column existed have no expiry and are rejected, so admins who were remembered sign in again once. ::: @@ -492,6 +499,7 @@ public function discoverFromClass(string $className): void; |---------|-------------| | `admin-auth:permissions:sync` | Inserts missing permissions, updates changed labels and groups, and lists permissions that are no longer registered with how many roles hold each. Deletes nothing | | `admin-auth:permissions:sync --prune` | Also deletes the unregistered permissions and their role assignments in one transaction. Never deletes a key containing `*`. Outside development and testing, needs `--force`; asks for confirmation when someone can answer | +| `auth:clear-tokens` | From marko/authentication: deletes expired rows from `remember_tokens` (`--force` deletes every row). See [Remember-Me Tokens](#remember-me-tokens) | ### RequiresPermission Attribute diff --git a/packages/docs-markdown/docs/packages/authentication.md b/packages/docs-markdown/docs/packages/authentication.md index 7cf49270e..b28ac810c 100644 --- a/packages/docs-markdown/docs/packages/authentication.md +++ b/packages/docs-markdown/docs/packages/authentication.md @@ -168,7 +168,7 @@ public function logout(): Response } ``` -`logout()` removes the user from the session, clears any remember token, and regenerates the session ID (deleting the old session), so the logged-out session ID cannot be reused on a shared machine. Other session data (for example, another guard's login) is kept; call `$session->destroy()` yourself if you want to discard everything. With [marko/security](/docs/packages/security/) installed, the CSRF token is also rotated on every login and logout. +`logout()` removes the user from the session, clears the remember token (with [per-device tokens](#per-device-tokens), only the current device's), and regenerates the session ID (deleting the old session), so the logged-out session ID cannot be reused on a shared machine. Other session data (for example, another guard's login) is kept; call `$session->destroy()` yourself if you want to discard everything. With [marko/security](/docs/packages/security/) installed, the CSRF token is also rotated on every login and logout. ### Making Users Authenticatable @@ -491,8 +491,33 @@ class LoginController } ``` +### Per-Device Tokens + +When a `RememberTokenStorageInterface` implementation is bound, every session guard issues **one token per device** instead of a single token on the user. [marko/admin-auth](/docs/packages/admin-auth/#remember-me-tokens) binds one backed by a `remember_tokens` table, which serves the admin guard and your app's own session guards alike. + +- `login($user, remember: true)` stores a new row (guard, user ID, a random selector, the SHA-256 hash of a random validator, the expiry, and the browser's `User-Agent`) and sends a `remember_{guard}` cookie holding `{selector}:{validator}`. +- A later request looks the row up by guard and selector and compares the validator's hash with `hash_equals()`. An unknown selector clears the cookie; a wrong validator (a tampered cookie) is rejected and the row is left alone. +- On success only the validator is rotated, as a compare-and-swap on the stored hash: of two requests racing with the same cookie, one logs in and the other is treated as a guest. The selector and the expiry are kept, so the [lifetime is fixed](#how-it-works) from the original login. +- An expired row, or one whose user no longer exists, is deleted along with the cookie. +- `logout()` deletes only the current device's row. The user's other devices stay remembered, and a remember-me login on one device never rotates another device's token. + +Without a binding, guards fall back to the single-column token described below. A cookie issued by that path (`{user id}|{token}`) is still honored after you install per-device storage: on its next use the column token is cleared and the device moves to its own row, so remembered users are not logged out by the upgrade. + +#### Clearing Expired Tokens + +Expired rows are deleted when they are presented, but rows from devices that never return remain. Purge them on a schedule: + +```bash +marko auth:clear-tokens # delete expired remember tokens +marko auth:clear-tokens --force # delete every remember token (logs every remembered device out) +``` + +The command calls `RememberTokenStorageInterface::clearExpiredTokens()` (or `clearAllTokens()` with `--force`), so it needs a storage binding such as marko/admin-auth's. + ### How It Works +These steps describe the single-column fallback, used when no `RememberTokenStorageInterface` is bound: + 1. `login($user, remember: true)` generates a random token, stores its SHA-256 hash and an expiry (`remember.lifetime` minutes from now) through `UserProviderInterface::updateRememberToken()`, and queues a `remember_{guard}` cookie (e.g. `remember_session`) holding `{user id}|{plain token}`. 2. `QueuedCookiesMiddleware` attaches the queued cookie to the response with `Response::withCookie()`. The package registers this middleware as global middleware and orders it after the session driver modules, so there is nothing to wire up. Cookies never go through `setcookie()`, so behavior is identical under PHP-FPM and RoadRunner. 3. On a later request with no authenticated session, `user()` reads the cookie, calls `retrieveByRememberToken($id, $hash)` with the **hash** of the cookie's token, and verifies it against `getRememberToken()` with a constant-time comparison. @@ -908,6 +933,7 @@ public function delete(string $name): void; ```php public function __construct(ClockInterface $clock, ?int $lifetimeMinutes = null); public function generate(): string; +public function generateSelector(): string; public function hash(string $token): string; public function validate(string $token, string $storedHash): bool; public function expiresAt(): DateTimeImmutable; @@ -916,6 +942,21 @@ public function minutesUntil(DateTimeImmutable $expiresAt): int; public function isExpired(DateTimeImmutable $createdAt): bool; ``` +### RememberTokenStorageInterface + +Per-device remember-me tokens. Bound by [marko/admin-auth](/docs/packages/admin-auth/#remember-me-tokens) (`RememberTokenRepository`); bind your own implementation to store them elsewhere. See [Per-Device Tokens](#per-device-tokens). + +```php +public function store(RememberTokenRecord $token): void; +public function findBySelector(string $guard, string $selector): ?RememberTokenRecord; +public function rotateValidator(string $guard, string $selector, string $currentValidatorHash, string $newValidatorHash): bool; +public function deleteBySelector(string $guard, string $selector): void; +public function clearExpiredTokens(): int; +public function clearAllTokens(): int; +``` + +`RememberTokenRecord` is a readonly value object with `guard`, `userId`, `selector`, `validatorHash`, `expiresAt` and `userAgent`. + ### RequestCookieJar ```php diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php b/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php index f52dd53e5..2c034da73 100644 --- a/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php @@ -8,16 +8,21 @@ use Marko\Authentication\Contracts\GuardInterface; use Marko\Authentication\Contracts\LoginThrottleInterface; use Marko\Authentication\Contracts\PasswordHasherInterface; +use Marko\Authentication\Contracts\RememberTokenStorageInterface; use Marko\Authentication\Cookie\RequestCookieJar; +use Marko\Authentication\Guard\GuardDriverRegistry; use Marko\Authentication\Hashing\BcryptPasswordHasher; use Marko\Authentication\Http\CurrentRequest; use Marko\Authentication\Middleware\QueuedCookiesMiddleware; use Marko\Authentication\Throttle\LoginThrottle; use Marko\Authentication\Throttle\NullLoginThrottle; use Marko\Authentication\Token\RememberTokenManager; +use Marko\Authentication\UserProviderResolver; use Marko\Cache\Contracts\CacheInterface; use Marko\Core\Container\ContainerInterface; +use Marko\Core\Event\EventDispatcherInterface; use Marko\RateLimiter\Contracts\RateLimitKeyResolverInterface; +use Marko\Session\Contracts\SessionInterface; use Psr\Clock\ClockInterface; // Mirrors packages/authentication/module.php — AuthManager and GuardInterface @@ -45,6 +50,22 @@ CookieJarInterface::class => function (ContainerInterface $container): CookieJarInterface { return $container->get(RequestCookieJar::class); }, + AuthManager::class => function (ContainerInterface $container): AuthManager { + return new AuthManager( + config: $container->get(AuthConfig::class), + session: $container->get(SessionInterface::class), + providerResolver: $container->get(UserProviderResolver::class), + eventDispatcher: $container->get(EventDispatcherInterface::class), + cookieJar: $container->get(CookieJarInterface::class), + rememberTokenManager: $container->get(RememberTokenManager::class), + guardDriverRegistry: $container->get(GuardDriverRegistry::class), + loginThrottle: $container->get(LoginThrottleInterface::class), + rememberTokenStorage: $container->has(RememberTokenStorageInterface::class) + ? $container->get(RememberTokenStorageInterface::class) + : null, + currentRequest: $container->get(CurrentRequest::class), + ); + }, GuardInterface::class => function (ContainerInterface $container): GuardInterface { return $container->get(AuthManager::class)->guard(); }, diff --git a/packages/testing/tests/fixtures/http-app/vendor/marko/authentication/module.php b/packages/testing/tests/fixtures/http-app/vendor/marko/authentication/module.php index a23ddaf1e..073ad6e69 100644 --- a/packages/testing/tests/fixtures/http-app/vendor/marko/authentication/module.php +++ b/packages/testing/tests/fixtures/http-app/vendor/marko/authentication/module.php @@ -8,16 +8,21 @@ use Marko\Authentication\Contracts\GuardInterface; use Marko\Authentication\Contracts\LoginThrottleInterface; use Marko\Authentication\Contracts\PasswordHasherInterface; +use Marko\Authentication\Contracts\RememberTokenStorageInterface; use Marko\Authentication\Cookie\RequestCookieJar; +use Marko\Authentication\Guard\GuardDriverRegistry; use Marko\Authentication\Hashing\BcryptPasswordHasher; use Marko\Authentication\Http\CurrentRequest; use Marko\Authentication\Middleware\QueuedCookiesMiddleware; use Marko\Authentication\Throttle\LoginThrottle; use Marko\Authentication\Throttle\NullLoginThrottle; use Marko\Authentication\Token\RememberTokenManager; +use Marko\Authentication\UserProviderResolver; use Marko\Cache\Contracts\CacheInterface; use Marko\Core\Container\ContainerInterface; +use Marko\Core\Event\EventDispatcherInterface; use Marko\RateLimiter\Contracts\RateLimitKeyResolverInterface; +use Marko\Session\Contracts\SessionInterface; use Psr\Clock\ClockInterface; return [ @@ -43,6 +48,22 @@ CookieJarInterface::class => function (ContainerInterface $container): CookieJarInterface { return $container->get(RequestCookieJar::class); }, + AuthManager::class => function (ContainerInterface $container): AuthManager { + return new AuthManager( + config: $container->get(AuthConfig::class), + session: $container->get(SessionInterface::class), + providerResolver: $container->get(UserProviderResolver::class), + eventDispatcher: $container->get(EventDispatcherInterface::class), + cookieJar: $container->get(CookieJarInterface::class), + rememberTokenManager: $container->get(RememberTokenManager::class), + guardDriverRegistry: $container->get(GuardDriverRegistry::class), + loginThrottle: $container->get(LoginThrottleInterface::class), + rememberTokenStorage: $container->has(RememberTokenStorageInterface::class) + ? $container->get(RememberTokenStorageInterface::class) + : null, + currentRequest: $container->get(CurrentRequest::class), + ); + }, GuardInterface::class => function (ContainerInterface $container): GuardInterface { return $container->get(AuthManager::class)->guard(); },