diff --git a/docs/2-features/04-authentication.md b/docs/2-features/04-authentication.md index 2e248a2f68..c8c757c2c6 100644 --- a/docs/2-features/04-authentication.md +++ b/docs/2-features/04-authentication.md @@ -90,6 +90,39 @@ final readonly class AuthenticationController } ``` +### Regenerating the session identifier + +Tempest automatically regenerates the session identifier when a model is authenticated or deauthenticated. Authentication keeps the existing session data, while deauthentication clears it before creating the new session. In both cases, the previous session is destroyed. + +You should also regenerate the session identifier whenever an authenticated session changes privilege level, such as after a password change, enabling two-factor authentication, impersonating another user, or escalating a user's role. Use the `regenerate()` method on {b`Tempest\Http\Session\SessionManager`} for these transitions: + +```php app/Authentication/TwoFactorController.php +use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionManager; + +final readonly class TwoFactorController +{ + public function __construct( + private Session $session, + private SessionManager $sessionManager, + ) {} + + public function enable(): void + { + // Enable two-factor authentication for the current user... + + $this->sessionManager->regenerate($this->session); + } +} +``` + +`regenerate()` destroys the old session, assigns a new identifier, carries the session data over and saves it. If the data must not survive the transition, clear the session before regenerating it: + +```php +$this->session->clear(); +$this->sessionManager->regenerate($this->session); +``` + ### Accessing the authenticated model You may access the currently authenticated model by injecting the {b`Tempest\Auth\Authentication\Authenticator`}. The authenticator provides a `current()` method that returns the currently authenticated model, or `null` if no model is authenticated. diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index 572b4fcfef..24d41356b8 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -43,15 +43,20 @@ public function authenticate(Authenticatable $authenticatable): void $this->currentId = $id; $this->currentClass = $class; $this->current = $authenticatable; + + // The session identifier must not survive a change in privilege level, or one + // known to an attacker before authentication stays valid afterwards. + $this->sessionManager->regenerate($this->session); } public function deauthenticate(): void { - $this->session->remove(self::AUTHENTICATABLE_KEY); - $this->session->remove(self::AUTHENTICATABLE_CLASS); $this->clearCurrent(); - $this->sessionManager->save($this->session); + // Discard session data so authenticated user data is not carried over + // to the new identifier. + $this->session->clear(); + $this->sessionManager->regenerate($this->session); } public function current(): ?Authenticatable diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php index 6e791e796d..172add2a7e 100644 --- a/packages/auth/tests/SessionAuthenticatorTest.php +++ b/packages/auth/tests/SessionAuthenticatorTest.php @@ -13,6 +13,7 @@ use Tempest\DateTime\DateTime; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; final class SessionAuthenticatorTest extends TestCase @@ -136,6 +137,51 @@ public function authenticate_replaces_a_cached_current_authenticatable(): void $this->assertSame(2, $current->id); } + #[Test] + public function authenticate_regenerates_the_session_identifier(): void + { + $session = $this->createSession(); + $sessionManager = new TestingSessionManager(); + + $authenticator = new SessionAuthenticator( + sessionManager: $sessionManager, + session: $session, + authenticatableResolver: new CountingAuthenticatableResolver(), + ); + + $authenticator->authenticate(new MemoizedAuthenticatable(id: 1)); + + $this->assertNotSame('test-session', (string) $session->id); + $this->assertSame(1, $sessionManager->deletedSessions); + $this->assertSame(1, $sessionManager->savedSessions); + $this->assertSame(1, $session->get(SessionAuthenticator::AUTHENTICATABLE_KEY)); + } + + #[Test] + public function deauthenticate_regenerates_the_session_identifier_and_discards_the_data(): void + { + $session = $this->createSession(); + $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1); + $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); + $session->set('key', 'value'); + $sessionManager = new TestingSessionManager(); + + $authenticator = new SessionAuthenticator( + sessionManager: $sessionManager, + session: $session, + authenticatableResolver: new CountingAuthenticatableResolver(), + ); + + $authenticator->deauthenticate(); + + $this->assertNotSame('test-session', (string) $session->id); + $this->assertSame(1, $sessionManager->deletedSessions); + $this->assertSame(1, $sessionManager->savedSessions); + $this->assertNull($session->get(SessionAuthenticator::AUTHENTICATABLE_KEY)); + $this->assertNull($session->get(SessionAuthenticator::AUTHENTICATABLE_CLASS)); + $this->assertNull($session->get('key')); + } + private function createSession(): Session { $now = DateTime::now(); @@ -186,10 +232,32 @@ public function resolveId(Authenticatable $authenticatable): int } } +final class TestingSessionIdResolver implements SessionIdResolver +{ + public function resolve(): SessionId + { + return new SessionId('test-session'); + } + + public function issueNewId(): SessionId + { + return new SessionId('regenerated-session-' . uniqid()); + } +} + final class TestingSessionManager implements SessionManager { public int $savedSessions = 0; + public int $deletedSessions = 0; + + private SessionIdResolver $sessionIdResolver; + + public function __construct() + { + $this->sessionIdResolver = new TestingSessionIdResolver(); + } + public function getOrCreate(SessionId $id): Session { $now = DateTime::now(); @@ -202,7 +270,19 @@ public function save(Session $session): void $this->savedSessions++; } - public function delete(Session $session): void {} + public function delete(Session $session): void + { + $this->deletedSessions++; + } + + public function regenerate(Session $session): void + { + $this->delete($session); + + $session->replaceId($this->sessionIdResolver->issueNewId()); + + $this->save($session); + } public function isValid(Session $session): bool { diff --git a/packages/http/src/Session/Managers/DatabaseSessionManager.php b/packages/http/src/Session/Managers/DatabaseSessionManager.php index ad6f1cf964..558d4e8766 100644 --- a/packages/http/src/Session/Managers/DatabaseSessionManager.php +++ b/packages/http/src/Session/Managers/DatabaseSessionManager.php @@ -11,6 +11,7 @@ use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; use function Tempest\Database\query; @@ -21,6 +22,7 @@ public function __construct( private Clock $clock, private SessionConfig $config, + private SessionIdResolver $sessionIdResolver, ) {} public function getOrCreate(SessionId $id): Session @@ -82,6 +84,15 @@ public function delete(Session $session): void event(new SessionDeleted($session->id)); } + public function regenerate(Session $session): void + { + $this->delete($session); + + $session->replaceId($this->sessionIdResolver->issueNewId()); + + $this->save($session); + } + public function isValid(Session $session): bool { return $this->clock->now()->before( diff --git a/packages/http/src/Session/Managers/FileSessionManager.php b/packages/http/src/Session/Managers/FileSessionManager.php index aebe39a599..fc24ee9fca 100644 --- a/packages/http/src/Session/Managers/FileSessionManager.php +++ b/packages/http/src/Session/Managers/FileSessionManager.php @@ -10,6 +10,7 @@ use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; use Tempest\Support\Filesystem; use Throwable; @@ -22,6 +23,7 @@ public function __construct( private Clock $clock, private FileSessionConfig $sessionConfig, // TODO: rename to $config, see RedisSessionManager and DatabaseSessionManager + private SessionIdResolver $sessionIdResolver, ) {} public function getOrCreate(SessionId $id): Session @@ -62,6 +64,15 @@ public function delete(Session $session): void event(new SessionDeleted($session->id)); } + public function regenerate(Session $session): void + { + $this->delete($session); + + $session->replaceId($this->sessionIdResolver->issueNewId()); + + $this->save($session); + } + public function isValid(Session $session): bool { return $this->clock->now()->before( diff --git a/packages/http/src/Session/Managers/RedisSessionManager.php b/packages/http/src/Session/Managers/RedisSessionManager.php index 6e19e9f4ab..04004c1f89 100644 --- a/packages/http/src/Session/Managers/RedisSessionManager.php +++ b/packages/http/src/Session/Managers/RedisSessionManager.php @@ -10,6 +10,7 @@ use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; use Tempest\KeyValue\Redis\Redis; use Tempest\Support\Str; @@ -23,6 +24,7 @@ public function __construct( private Clock $clock, private Redis $redis, private RedisSessionConfig $config, + private SessionIdResolver $sessionIdResolver, ) {} public function getOrCreate(SessionId $id): Session @@ -61,6 +63,15 @@ public function delete(Session $session): void event(new SessionDeleted($session->id)); } + public function regenerate(Session $session): void + { + $this->delete($session); + + $session->replaceId($this->sessionIdResolver->issueNewId()); + + $this->save($session); + } + public function isValid(Session $session): bool { return $this->clock->now()->before( diff --git a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php index 4c5077ac76..25b8831ff8 100644 --- a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php +++ b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php @@ -30,27 +30,37 @@ public function __construct( public function resolve(): SessionId { - $sessionKey = str($this->appConfig->name ?? 'tempest') - ->snake() - ->append('_session_id') - ->toString(); - - $id = $this->request->getCookie($sessionKey)?->value; + $id = $this->request->getCookie($this->getSessionKey())?->value; if (! $id) { - $id = (string) Uuid::v4(); - - $this->cookies->add(new Cookie( - key: $sessionKey, - value: $id, - expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration), - path: '/', - secure: Str\starts_with($this->appConfig->baseUri, needles: 'https'), - httpOnly: true, - sameSite: SameSite::LAX, - )); + return $this->issueNewId(); } return new SessionId($id); } + + public function issueNewId(): SessionId + { + $id = (string) Uuid::v4(); + + $this->cookies->add(new Cookie( + key: $this->getSessionKey(), + value: $id, + expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration), + path: '/', + secure: Str\starts_with($this->appConfig->baseUri, needles: 'https'), + httpOnly: true, + sameSite: SameSite::LAX, + )); + + return new SessionId($id); + } + + private function getSessionKey(): string + { + return str($this->appConfig->name ?? 'tempest') + ->snake() + ->append('_session_id') + ->toString(); + } } diff --git a/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php b/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php index 93f540cde6..bef29a19cf 100644 --- a/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php +++ b/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php @@ -35,4 +35,9 @@ public function resolve(): SessionId id: $this->request->headers[$sessionKey] ?? Uuid::v4()->toString(), ); } + + public function issueNewId(): SessionId + { + return new SessionId(id: Uuid::v4()->toString()); + } } diff --git a/packages/http/src/Session/Session.php b/packages/http/src/Session/Session.php index 78846c4a92..3b749ccaf1 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -132,6 +132,15 @@ public function cleanup(): void } } + /** + * @internal Prefer {@see SessionManager::regenerate()}, which also destroys the session that + * is being replaced and sends the new identifier to the client. + */ + public function replaceId(SessionId $id): void + { + $this->id = $id; + } + /** * Clears all values from the session. */ diff --git a/packages/http/src/Session/SessionIdResolver.php b/packages/http/src/Session/SessionIdResolver.php index 6b49d69ddb..782112e29d 100644 --- a/packages/http/src/Session/SessionIdResolver.php +++ b/packages/http/src/Session/SessionIdResolver.php @@ -6,5 +6,15 @@ interface SessionIdResolver { + /** + * Resolves the identifier sent by the client, creating a new one if there is none. + */ public function resolve(): SessionId; + + /** + * Creates a new identifier and sends it to the client, replacing the one it was using. + * + * @see SessionManager::regenerate() + */ + public function issueNewId(): SessionId; } diff --git a/packages/http/src/Session/SessionManager.php b/packages/http/src/Session/SessionManager.php index cc904b1d9b..ad0b901bb9 100644 --- a/packages/http/src/Session/SessionManager.php +++ b/packages/http/src/Session/SessionManager.php @@ -21,6 +21,18 @@ public function save(Session $session): void; */ public function delete(Session $session): void; + /** + * Assigns a new identifier to the session, destroying the session it replaces + * and sending the new identifier to the client. Session data is carried over. + * + * This protects against session fixation, and should be done whenever the session + * changes privilege level - such as authentication or a password change. + * + * @see https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html + * @see https://owasp.org/www-community/attacks/Session_fixation + */ + public function regenerate(Session $session): void; + /** * Determines whether the session is still valid. */ diff --git a/packages/upgrade/config/sets/level/up-to-tempest-320.php b/packages/upgrade/config/sets/level/up-to-tempest-320.php new file mode 100644 index 0000000000..459619f20f --- /dev/null +++ b/packages/upgrade/config/sets/level/up-to-tempest-320.php @@ -0,0 +1,18 @@ +sets([ + TempestSetList::TEMPEST_20, + TempestSetList::TEMPEST_28, + TempestSetList::TEMPEST_30, + TempestSetList::TEMPEST_34, + TempestSetList::TEMPEST_310, + TempestSetList::TEMPEST_314, + TempestSetList::TEMPEST_320, + ]); +}; diff --git a/packages/upgrade/config/sets/tempest320.php b/packages/upgrade/config/sets/tempest320.php new file mode 100644 index 0000000000..f93051957f --- /dev/null +++ b/packages/upgrade/config/sets/tempest320.php @@ -0,0 +1,15 @@ +rule(UpdateSessionManagerImplementationsRector::class); + $config->rule(UpdateSessionIdResolverImplementationsRector::class); +}; diff --git a/packages/upgrade/src/Set/TempestLevelSetList.php b/packages/upgrade/src/Set/TempestLevelSetList.php index 458d7f992e..04b61e4ac1 100644 --- a/packages/upgrade/src/Set/TempestLevelSetList.php +++ b/packages/upgrade/src/Set/TempestLevelSetList.php @@ -17,4 +17,6 @@ final class TempestLevelSetList public const string UP_TO_TEMPEST_310 = __DIR__ . '/../../config/sets/level/up-to-tempest-310.php'; public const string UP_TO_TEMPEST_314 = __DIR__ . '/../../config/sets/level/up-to-tempest-314.php'; + + public const string UP_TO_TEMPEST_320 = __DIR__ . '/../../config/sets/level/up-to-tempest-320.php'; } diff --git a/packages/upgrade/src/Set/TempestSetList.php b/packages/upgrade/src/Set/TempestSetList.php index e786902548..df989af5ba 100644 --- a/packages/upgrade/src/Set/TempestSetList.php +++ b/packages/upgrade/src/Set/TempestSetList.php @@ -17,4 +17,6 @@ final class TempestSetList public const string TEMPEST_310 = __DIR__ . '/../../config/sets/tempest310.php'; public const string TEMPEST_314 = __DIR__ . '/../../config/sets/tempest314.php'; + + public const string TEMPEST_320 = __DIR__ . '/../../config/sets/tempest320.php'; } diff --git a/packages/upgrade/src/Tempest320/UpdateSessionIdResolverImplementationsRector.php b/packages/upgrade/src/Tempest320/UpdateSessionIdResolverImplementationsRector.php new file mode 100644 index 0000000000..f412f52b57 --- /dev/null +++ b/packages/upgrade/src/Tempest320/UpdateSessionIdResolverImplementationsRector.php @@ -0,0 +1,60 @@ +implements, fn (Name $name) => $this->isName($name, SessionIdResolver::class))) { + return null; + } + + if ($node->getMethod('issueNewId') instanceof ClassMethod) { + return null; + } + + $node->stmts[] = $this->createIssueNewIdMethod(); + + return $node; + } + + private function createIssueNewIdMethod(): ClassMethod + { + $method = $this->nodeFactory->createPublicMethod('issueNewId'); + + $method->returnType = new FullyQualified(SessionId::class); + $method->stmts = [ + new Expression(new Throw_(new New_( + new FullyQualified(BadMethodCallException::class), + $this->nodeFactory->createArgs(['issueNewId() is not implemented yet.']), + ))), + ]; + + return $method; + } +} diff --git a/packages/upgrade/src/Tempest320/UpdateSessionManagerImplementationsRector.php b/packages/upgrade/src/Tempest320/UpdateSessionManagerImplementationsRector.php new file mode 100644 index 0000000000..c932757485 --- /dev/null +++ b/packages/upgrade/src/Tempest320/UpdateSessionManagerImplementationsRector.php @@ -0,0 +1,63 @@ +implements, fn (Name $name) => $this->isName($name, SessionManager::class))) { + return null; + } + + if ($node->getMethod('regenerate') instanceof ClassMethod) { + return null; + } + + $node->stmts[] = $this->createRegenerateMethod(); + + return $node; + } + + private function createRegenerateMethod(): ClassMethod + { + $method = $this->nodeFactory->createPublicMethod('regenerate'); + + $method->params[] = $this->nodeFactory->createParamFromNameAndType('session', new ObjectType(Session::class)); + $method->returnType = new Identifier('void'); + $method->stmts = [ + new Expression(new Throw_(new New_( + new FullyQualified(BadMethodCallException::class), + $this->nodeFactory->createArgs(['regenerate() is not implemented yet.']), + ))), + ]; + + return $method; + } +} diff --git a/packages/upgrade/tests/Tempest320/Fixtures/AliasedSessionImplementations.input.php b/packages/upgrade/tests/Tempest320/Fixtures/AliasedSessionImplementations.input.php new file mode 100644 index 0000000000..1377a55f06 --- /dev/null +++ b/packages/upgrade/tests/Tempest320/Fixtures/AliasedSessionImplementations.input.php @@ -0,0 +1,41 @@ + new RectorTester(__DIR__ . '/tempest320_rector.php'); + } + + #[Test] + public function session_implementation_methods_are_added(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/SessionImplementations.input.php') + ->assertContains('public function regenerate(Session $session): void') + ->assertContains('public function issueNewId(): SessionId') + ->assertContains("throw new BadMethodCallException('regenerate() is not implemented yet.');") + ->assertContains("throw new BadMethodCallException('issueNewId() is not implemented yet.');"); + } + + #[Test] + public function aliased_session_implementations_are_updated(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/AliasedSessionImplementations.input.php') + ->assertContains('public function regenerate(Session $session): void') + ->assertContains('public function issueNewId(): SessionId'); + } + + #[Test] + public function existing_session_methods_are_not_overwritten(): void + { + $this->assertSame( + '', + $this->rector + ->runFixture(__DIR__ . '/Fixtures/ExistingSessionImplementations.input.php') + ->actual, + ); + } +} diff --git a/packages/upgrade/tests/Tempest320/tempest320_rector.php b/packages/upgrade/tests/Tempest320/tempest320_rector.php new file mode 100644 index 0000000000..f2d9fd8799 --- /dev/null +++ b/packages/upgrade/tests/Tempest320/tempest320_rector.php @@ -0,0 +1,11 @@ +withSets([TempestSetList::TEMPEST_320]) + ->withCache(cacheClass: MemoryCacheStorage::class); diff --git a/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php b/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php index b061e602b3..ae325483ed 100644 --- a/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php +++ b/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php @@ -23,6 +23,7 @@ use Tempest\Http\Session\Config\FileSessionConfig; use Tempest\Http\Session\Managers\FileSessionManager; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -47,6 +48,7 @@ protected function configure(): void $this->container->singleton(SessionManager::class, fn () => new FileSessionManager( $this->container->get(Clock::class), $this->container->get(FileSessionConfig::class), + $this->container->get(SessionIdResolver::class), )); $this->database->migrate(CreateMigrationsTable::class, CreateUsersTableMigration::class, CreateApiKeysTableMigration::class); diff --git a/tests/Integration/Http/CookieSessionIdResolverTest.php b/tests/Integration/Http/CookieSessionIdResolverTest.php index 4a0b4349a5..701dee703e 100644 --- a/tests/Integration/Http/CookieSessionIdResolverTest.php +++ b/tests/Integration/Http/CookieSessionIdResolverTest.php @@ -44,6 +44,19 @@ public function set_cookie_with_insecure_base_uri(): void $this->assertFalse($cookie->secure); } + #[Test] + public function issue_new_id_replaces_the_cookie(): void + { + $cookies = $this->container->get(CookieManager::class); + $resolver = $this->container->get(CookieSessionIdResolver::class); + + $previousId = (string) $resolver->resolve(); + $id = (string) $resolver->issueNewId(); + + $this->assertNotSame($previousId, $id); + $this->assertSame($id, $cookies->get('tempest_session_id')->value); + } + #[Test] public function cookie_name(): void { diff --git a/tests/Integration/Http/DatabaseSessionTest.php b/tests/Integration/Http/DatabaseSessionTest.php index f6315c2a45..926c44fa5c 100644 --- a/tests/Integration/Http/DatabaseSessionTest.php +++ b/tests/Integration/Http/DatabaseSessionTest.php @@ -19,6 +19,7 @@ use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; use Tempest\Support\Random; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -46,6 +47,7 @@ protected function configure(): void $this->container->singleton(SessionManager::class, fn () => new DatabaseSessionManager( $this->container->get(Clock::class), $this->container->get(SessionConfig::class), + $this->container->get(SessionIdResolver::class), )); $this->database->reset(migrate: false); @@ -163,6 +165,24 @@ public function delete_removes_session_from_database(): void ); } + #[Test] + public function regenerate_replaces_the_session_record(): void + { + $this->eventBus->preventEventHandling(); + + $this->session->set('magic_type', 'offensive'); + $this->manager->save($this->session); + + $previousId = $this->session->id; + + $this->manager->regenerate($this->session); + + $this->assertNotSame((string) $previousId, (string) $this->session->id); + $this->assertSessionNotExistsInDatabase($previousId); + $this->assertSessionExistsInDatabase($this->session->id); + $this->assertSessionDataInDatabase($this->session->id, ['magic_type' => 'offensive']); + } + #[Test] public function is_valid_checks_expiration(): void { diff --git a/tests/Integration/Http/FileSessionTest.php b/tests/Integration/Http/FileSessionTest.php index 2e624e4f87..c5636f98ee 100644 --- a/tests/Integration/Http/FileSessionTest.php +++ b/tests/Integration/Http/FileSessionTest.php @@ -16,6 +16,7 @@ use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; use Tempest\Support\Filesystem; use Tempest\Support\Path; @@ -52,6 +53,7 @@ protected function configure(): void $this->container->singleton(SessionManager::class, fn () => new FileSessionManager( $this->container->get(Clock::class), $this->container->get(FileSessionConfig::class), + $this->container->get(SessionIdResolver::class), )); } @@ -154,6 +156,26 @@ public function delete_removes_session_file(): void ); } + #[Test] + public function regenerate_replaces_the_session_file(): void + { + $this->session->set('key', 'value'); + $this->manager->save($this->session); + + $previousPath = Path\normalize($this->path, 'sessions', (string) $this->session->id); + $previousId = (string) $this->session->id; + + $this->manager->regenerate($this->session); + + $this->assertNotSame($previousId, (string) $this->session->id); + $this->assertFileDoesNotExist($previousPath); + + $path = Path\normalize($this->path, 'sessions', (string) $this->session->id); + + $this->assertFileExists($path); + $this->assertSame('value', $this->session->get('key')); + } + #[Test] public function is_valid_checks_expiration(): void { diff --git a/tests/Integration/Http/RedisSessionTest.php b/tests/Integration/Http/RedisSessionTest.php index ac19c832e0..e103ac9a71 100644 --- a/tests/Integration/Http/RedisSessionTest.php +++ b/tests/Integration/Http/RedisSessionTest.php @@ -16,6 +16,7 @@ use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; use Tempest\KeyValue\Redis\Redis; use Tempest\Support\Random; @@ -47,6 +48,7 @@ protected function configure(): void clock: $this->container->get(Clock::class), redis: $this->container->get(Redis::class), config: $this->container->get(RedisSessionConfig::class), + sessionIdResolver: $this->container->get(SessionIdResolver::class), )); try { @@ -173,6 +175,24 @@ public function delete_removes_session_from_redis(): void ); } + #[Test] + public function regenerate_replaces_the_session_key(): void + { + $this->eventBus->preventEventHandling(); + + $this->session->set('magic_type', 'offensive'); + $this->manager->save($this->session); + + $previousId = $this->session->id; + + $this->manager->regenerate($this->session); + + $this->assertNotSame((string) $previousId, (string) $this->session->id); + $this->assertSessionNotExistsInRedis($previousId); + $this->assertSessionExistsInRedis($this->session->id); + $this->assertSessionDataInRedis($this->session->id, ['magic_type' => 'offensive']); + } + #[Test] public function is_valid_checks_expiration(): void { diff --git a/tests/Integration/Http/SessionCleanupStrategyTest.php b/tests/Integration/Http/SessionCleanupStrategyTest.php index 444ac468f2..783832ef9f 100644 --- a/tests/Integration/Http/SessionCleanupStrategyTest.php +++ b/tests/Integration/Http/SessionCleanupStrategyTest.php @@ -130,6 +130,8 @@ public function save(Session $session): void public function delete(Session $session): void {} + public function regenerate(Session $session): void {} + public function isValid(Session $session): bool { return true; diff --git a/tests/Integration/Http/SessionTest.php b/tests/Integration/Http/SessionTest.php index e6d22a9107..7642f2a783 100644 --- a/tests/Integration/Http/SessionTest.php +++ b/tests/Integration/Http/SessionTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionId; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -139,6 +140,17 @@ public function clear(): void $this->assertEmpty($this->session->all()); } + #[Test] + public function replace_id_preserves_data(): void + { + $this->session->set('key', 'value'); + + $this->session->replaceId(new SessionId('new_session')); + + $this->assertSame('new_session', (string) $this->session->id); + $this->assertSame('value', $this->session->get('key')); + } + #[Test] public function session_is_reset(): void { diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 1b06897d6b..b296de0efd 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -480,6 +480,8 @@ public function save(Session $session): void public function delete(Session $session): void {} + public function regenerate(Session $session): void {} + public function isValid(Session $session): bool { return true;