From 9321d08bf9443d6014a6cf80c207e6df35bc789b Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Mon, 7 Sep 2026 00:29:55 +0100 Subject: [PATCH 1/6] feat(http)!: regenerate the session identifier on authentication --- .../AuthenticatorInitializer.php | 4 +- .../Authentication/SessionAuthenticator.php | 14 ++-- .../auth/tests/SessionAuthenticatorTest.php | 84 +++++++++++++++++-- .../Resolvers/CookieSessionIdResolver.php | 44 ++++++---- .../Resolvers/HeaderSessionIdResolver.php | 5 ++ packages/http/src/Session/Session.php | 15 ++++ .../http/src/Session/SessionIdResolver.php | 10 +++ .../http/src/Session/SessionRegenerator.php | 36 ++++++++ .../Http/SessionRegeneratorTest.php | 76 +++++++++++++++++ 9 files changed, 258 insertions(+), 30 deletions(-) create mode 100644 packages/http/src/Session/SessionRegenerator.php create mode 100644 tests/Integration/Http/SessionRegeneratorTest.php diff --git a/packages/auth/src/Authentication/AuthenticatorInitializer.php b/packages/auth/src/Authentication/AuthenticatorInitializer.php index e87555096b..92a86d549c 100644 --- a/packages/auth/src/Authentication/AuthenticatorInitializer.php +++ b/packages/auth/src/Authentication/AuthenticatorInitializer.php @@ -8,7 +8,7 @@ use Tempest\Container\Initializer; use Tempest\Container\Singleton; use Tempest\Http\Session\Session; -use Tempest\Http\Session\SessionManager; +use Tempest\Http\Session\SessionRegenerator; final readonly class AuthenticatorInitializer implements Initializer { @@ -16,9 +16,9 @@ public function initialize(Container $container): Authenticator { return new SessionAuthenticator( - sessionManager: $container->get(SessionManager::class), session: $container->get(Session::class), authenticatableResolver: $container->get(AuthenticatableResolver::class), + sessionRegenerator: $container->get(SessionRegenerator::class), ); } } diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index 572b4fcfef..ddf52330ea 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -5,7 +5,7 @@ namespace Tempest\Auth\Authentication; use Tempest\Http\Session\Session; -use Tempest\Http\Session\SessionManager; +use Tempest\Http\Session\SessionRegenerator; final class SessionAuthenticator implements Authenticator { @@ -20,9 +20,9 @@ final class SessionAuthenticator implements Authenticator private ?Authenticatable $current = null; public function __construct( - private readonly SessionManager $sessionManager, private readonly Session $session, private readonly AuthenticatableResolver $authenticatableResolver, + private readonly SessionRegenerator $sessionRegenerator, ) {} public function authenticate(Authenticatable $authenticatable): void @@ -43,15 +43,19 @@ 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->sessionRegenerator->regenerate(); } public function deauthenticate(): void { - $this->session->remove(self::AUTHENTICATABLE_KEY); - $this->session->remove(self::AUTHENTICATABLE_CLASS); $this->clearCurrent(); - $this->sessionManager->save($this->session); + // Regenerate session without preserving data to prevent session fixation + // and purge all authenticated user data. + $this->sessionRegenerator->regenerate(preserveData: false); } public function current(): ?Authenticatable diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php index 6e791e796d..88b4f856ca 100644 --- a/packages/auth/tests/SessionAuthenticatorTest.php +++ b/packages/auth/tests/SessionAuthenticatorTest.php @@ -13,7 +13,9 @@ use Tempest\DateTime\DateTime; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionId; +use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; +use Tempest\Http\Session\SessionRegenerator; final class SessionAuthenticatorTest extends TestCase { @@ -27,9 +29,9 @@ public function current_memoizes_the_resolved_authenticatable_for_the_current_se $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( - sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, + sessionRegenerator: $this->createRegenerator($session), ); $this->assertSame($authenticatable, $authenticator->current()); @@ -46,9 +48,9 @@ public function current_memoizes_a_missing_authenticatable_for_the_current_sessi $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( - sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, + sessionRegenerator: $this->createRegenerator($session), ); $this->assertNull($authenticator->current()); @@ -68,9 +70,9 @@ public function current_re_resolves_when_the_session_identity_changes(): void $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( - sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, + sessionRegenerator: $this->createRegenerator($session), ); $current = $authenticator->current(); @@ -95,9 +97,9 @@ public function reset_clears_the_cached_current_authenticatable(): void $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( - sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, + sessionRegenerator: $this->createRegenerator($session), ); $this->assertSame($authenticatable, $authenticator->current()); @@ -120,9 +122,9 @@ public function authenticate_replaces_a_cached_current_authenticatable(): void $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( - sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, + sessionRegenerator: $this->createRegenerator($session), ); $current = $authenticator->current(); @@ -136,6 +138,58 @@ 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( + session: $session, + authenticatableResolver: new CountingAuthenticatableResolver(), + sessionRegenerator: $this->createRegenerator($session, $sessionManager), + ); + + $authenticator->authenticate(new MemoizedAuthenticatable(id: 1)); + + $this->assertNotSame('test-session', (string) $session->id); + $this->assertSame(1, $sessionManager->deletedSessions); + $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( + session: $session, + authenticatableResolver: new CountingAuthenticatableResolver(), + sessionRegenerator: $this->createRegenerator($session, $sessionManager), + ); + + $authenticator->deauthenticate(); + + $this->assertNotSame('test-session', (string) $session->id); + $this->assertSame(1, $sessionManager->deletedSessions); + $this->assertNull($session->get(SessionAuthenticator::AUTHENTICATABLE_KEY)); + $this->assertNull($session->get(SessionAuthenticator::AUTHENTICATABLE_CLASS)); + $this->assertNull($session->get('key')); + } + + private function createRegenerator(Session $session, ?SessionManager $sessionManager = null): SessionRegenerator + { + return new SessionRegenerator( + sessionManager: $sessionManager ?? new TestingSessionManager(), + session: $session, + sessionIdResolver: new TestingSessionIdResolver(), + ); + } + private function createSession(): Session { $now = DateTime::now(); @@ -186,10 +240,25 @@ public function resolveId(Authenticatable $authenticatable): int } } +final class TestingSessionIdResolver implements SessionIdResolver +{ + public function resolve(): SessionId + { + return new SessionId('test-session'); + } + + public function regenerate(): SessionId + { + return new SessionId('regenerated-session-' . uniqid()); + } +} + final class TestingSessionManager implements SessionManager { public int $savedSessions = 0; + public int $deletedSessions = 0; + public function getOrCreate(SessionId $id): Session { $now = DateTime::now(); @@ -202,7 +271,10 @@ public function save(Session $session): void $this->savedSessions++; } - public function delete(Session $session): void {} + public function delete(Session $session): void + { + $this->deletedSessions++; + } public function isValid(Session $session): bool { diff --git a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php index 4c5077ac76..d92307fb6e 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->regenerate(); } return new SessionId($id); } + + public function regenerate(): 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 ecc26b7245..81163fe5f0 100644 --- a/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php +++ b/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php @@ -30,4 +30,9 @@ public function resolve(): SessionId id: $this->request->headers[$sessionKey] ?? Uuid::v4()->toString(), ); } + + public function regenerate(): 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..da1ad34cbb 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -132,6 +132,21 @@ public function cleanup(): void } } + /** + * Assigns a new identifier to the session, optionally discarding all its data. + * + * Prefer {@see SessionRegenerator}, which also destroys the session that is being + * replaced and sends the new identifier to the client. + */ + public function replaceId(SessionId $id, bool $preserveData = true): void + { + $this->id = $id; + + if (! $preserveData) { + $this->clear(); + } + } + /** * Clears all values from the session. */ diff --git a/packages/http/src/Session/SessionIdResolver.php b/packages/http/src/Session/SessionIdResolver.php index 6b49d69ddb..58114d6726 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 SessionRegenerator + */ + public function regenerate(): SessionId; } diff --git a/packages/http/src/Session/SessionRegenerator.php b/packages/http/src/Session/SessionRegenerator.php new file mode 100644 index 0000000000..6453c13d48 --- /dev/null +++ b/packages/http/src/Session/SessionRegenerator.php @@ -0,0 +1,36 @@ +sessionManager->delete($this->session); + + $this->session->replaceId( + id: $this->sessionIdResolver->regenerate(), + preserveData: $preserveData, + ); + + $this->sessionManager->save($this->session); + } +} diff --git a/tests/Integration/Http/SessionRegeneratorTest.php b/tests/Integration/Http/SessionRegeneratorTest.php new file mode 100644 index 0000000000..07498dd1bd --- /dev/null +++ b/tests/Integration/Http/SessionRegeneratorTest.php @@ -0,0 +1,76 @@ + $this->container->get(Session::class); + } + + private SessionRegenerator $regenerator { + get => $this->container->get(SessionRegenerator::class); + } + + #[Test] + public function assigns_a_new_identifier_and_keeps_data(): void + { + $this->session->set('key', 'value'); + $previousId = (string) $this->session->id; + + $this->regenerator->regenerate(); + + $this->assertNotSame($previousId, (string) $this->session->id); + $this->assertSame('value', $this->session->get('key')); + } + + #[Test] + public function discards_data_when_it_is_not_preserved(): void + { + $this->session->set('key', 'value'); + + $this->regenerator->regenerate(preserveData: false); + + $this->assertNull($this->session->get('key')); + } + + #[Test] + public function destroys_the_session_it_replaces(): void + { + $sessionManager = $this->container->get(SessionManager::class); + + $this->session->set('key', 'value'); + $previousId = $this->session->id; + + $this->regenerator->regenerate(); + + $previousSession = $sessionManager->getOrCreate($previousId); + + $this->assertNull($previousSession->get('key')); + } + + #[Test] + public function sends_the_new_identifier_to_the_client(): void + { + $cookies = $this->container->get(CookieManager::class); + + $this->regenerator->regenerate(); + + $this->assertSame( + (string) $this->session->id, + $cookies->get('tempest_session_id')?->value, + ); + } +} From 364650cf5c40c7dec5eaf4bfc4b6c2add6da3a3e Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 7 Sep 2026 18:51:02 +0200 Subject: [PATCH 2/6] refactor: clean up public api --- .../Authentication/SessionAuthenticator.php | 2 +- .../auth/tests/SessionAuthenticatorTest.php | 2 +- .../Resolvers/CookieSessionIdResolver.php | 4 ++-- .../Resolvers/HeaderSessionIdResolver.php | 2 +- packages/http/src/Session/Session.php | 10 ++------ .../http/src/Session/SessionIdResolver.php | 2 +- .../http/src/Session/SessionRegenerator.php | 23 +++++++++++++------ .../Http/SessionRegeneratorTest.php | 4 ++-- tests/Integration/Http/SessionTest.php | 12 ++++++++++ 9 files changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index ddf52330ea..cc09a6cc39 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -55,7 +55,7 @@ public function deauthenticate(): void // Regenerate session without preserving data to prevent session fixation // and purge all authenticated user data. - $this->sessionRegenerator->regenerate(preserveData: false); + $this->sessionRegenerator->invalidate(); } public function current(): ?Authenticatable diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php index 88b4f856ca..45dd4e459a 100644 --- a/packages/auth/tests/SessionAuthenticatorTest.php +++ b/packages/auth/tests/SessionAuthenticatorTest.php @@ -247,7 +247,7 @@ public function resolve(): SessionId return new SessionId('test-session'); } - public function regenerate(): SessionId + public function issueNewId(): SessionId { return new SessionId('regenerated-session-' . uniqid()); } diff --git a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php index d92307fb6e..25b8831ff8 100644 --- a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php +++ b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php @@ -33,13 +33,13 @@ public function resolve(): SessionId $id = $this->request->getCookie($this->getSessionKey())?->value; if (! $id) { - return $this->regenerate(); + return $this->issueNewId(); } return new SessionId($id); } - public function regenerate(): SessionId + public function issueNewId(): SessionId { $id = (string) Uuid::v4(); diff --git a/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php b/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php index 81163fe5f0..765c3513c9 100644 --- a/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php +++ b/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php @@ -31,7 +31,7 @@ public function resolve(): SessionId ); } - public function regenerate(): SessionId + 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 da1ad34cbb..677485a66f 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -133,18 +133,12 @@ public function cleanup(): void } /** - * Assigns a new identifier to the session, optionally discarding all its data. - * - * Prefer {@see SessionRegenerator}, which also destroys the session that is being + * @internal Prefer {@see SessionRegenerator}, which also destroys the session that is being * replaced and sends the new identifier to the client. */ - public function replaceId(SessionId $id, bool $preserveData = true): void + public function replaceId(SessionId $id): void { $this->id = $id; - - if (! $preserveData) { - $this->clear(); - } } /** diff --git a/packages/http/src/Session/SessionIdResolver.php b/packages/http/src/Session/SessionIdResolver.php index 58114d6726..aa52293593 100644 --- a/packages/http/src/Session/SessionIdResolver.php +++ b/packages/http/src/Session/SessionIdResolver.php @@ -16,5 +16,5 @@ public function resolve(): SessionId; * * @see SessionRegenerator */ - public function regenerate(): SessionId; + public function issueNewId(): SessionId; } diff --git a/packages/http/src/Session/SessionRegenerator.php b/packages/http/src/Session/SessionRegenerator.php index 6453c13d48..9ee2afa123 100644 --- a/packages/http/src/Session/SessionRegenerator.php +++ b/packages/http/src/Session/SessionRegenerator.php @@ -19,17 +19,26 @@ public function __construct( ) {} /** - * Assigns a new ID to the current session, carrying over data by default. + * Assigns a new ID to the current session, carrying over data. */ - public function regenerate(bool $preserveData = true): void + public function regenerate(): void { - // Destroy the old session to prevent parallel active sessions. $this->sessionManager->delete($this->session); - $this->session->replaceId( - id: $this->sessionIdResolver->regenerate(), - preserveData: $preserveData, - ); + $this->session->replaceId($this->sessionIdResolver->issueNewId()); + + $this->sessionManager->save($this->session); + } + + /** + * Assigns a new ID to the current session, discarding all data. + */ + public function invalidate(): void + { + $this->sessionManager->delete($this->session); + + $this->session->replaceId($this->sessionIdResolver->issueNewId()); + $this->session->clear(); $this->sessionManager->save($this->session); } diff --git a/tests/Integration/Http/SessionRegeneratorTest.php b/tests/Integration/Http/SessionRegeneratorTest.php index 07498dd1bd..9518b6e099 100644 --- a/tests/Integration/Http/SessionRegeneratorTest.php +++ b/tests/Integration/Http/SessionRegeneratorTest.php @@ -37,11 +37,11 @@ public function assigns_a_new_identifier_and_keeps_data(): void } #[Test] - public function discards_data_when_it_is_not_preserved(): void + public function invalidate_discards_data(): void { $this->session->set('key', 'value'); - $this->regenerator->regenerate(preserveData: false); + $this->regenerator->invalidate(); $this->assertNull($this->session->get('key')); } 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 { From 5f5d68d9abeb2d31ef522967be0095230ed610d7 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Wed, 9 Sep 2026 10:48:20 +0100 Subject: [PATCH 3/6] refactor: narrow the regenerator to the identifier swap --- .../AuthenticatorInitializer.php | 2 ++ .../Authentication/SessionAuthenticator.php | 13 +++++++++--- .../auth/tests/SessionAuthenticatorTest.php | 9 +++++++++ .../http/src/Session/SessionRegenerator.php | 20 ++++--------------- .../Http/SessionRegeneratorTest.php | 10 ---------- 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/auth/src/Authentication/AuthenticatorInitializer.php b/packages/auth/src/Authentication/AuthenticatorInitializer.php index 92a86d549c..620bb2c19b 100644 --- a/packages/auth/src/Authentication/AuthenticatorInitializer.php +++ b/packages/auth/src/Authentication/AuthenticatorInitializer.php @@ -8,6 +8,7 @@ use Tempest\Container\Initializer; use Tempest\Container\Singleton; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionManager; use Tempest\Http\Session\SessionRegenerator; final readonly class AuthenticatorInitializer implements Initializer @@ -16,6 +17,7 @@ public function initialize(Container $container): Authenticator { return new SessionAuthenticator( + sessionManager: $container->get(SessionManager::class), session: $container->get(Session::class), authenticatableResolver: $container->get(AuthenticatableResolver::class), sessionRegenerator: $container->get(SessionRegenerator::class), diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index cc09a6cc39..19da7e2528 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -5,6 +5,7 @@ namespace Tempest\Auth\Authentication; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionManager; use Tempest\Http\Session\SessionRegenerator; final class SessionAuthenticator implements Authenticator @@ -20,6 +21,7 @@ final class SessionAuthenticator implements Authenticator private ?Authenticatable $current = null; public function __construct( + private readonly SessionManager $sessionManager, private readonly Session $session, private readonly AuthenticatableResolver $authenticatableResolver, private readonly SessionRegenerator $sessionRegenerator, @@ -47,15 +49,20 @@ public function authenticate(Authenticatable $authenticatable): void // The session identifier must not survive a change in privilege level, or one // known to an attacker before authentication stays valid afterwards. $this->sessionRegenerator->regenerate(); + + $this->sessionManager->save($this->session); } public function deauthenticate(): void { $this->clearCurrent(); - // Regenerate session without preserving data to prevent session fixation - // and purge all authenticated user data. - $this->sessionRegenerator->invalidate(); + // Discard session data so authenticated user data is not carried over + // to the new identifier. + $this->session->clear(); + $this->sessionRegenerator->regenerate(); + + $this->sessionManager->save($this->session); } public function current(): ?Authenticatable diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php index 45dd4e459a..e290421d37 100644 --- a/packages/auth/tests/SessionAuthenticatorTest.php +++ b/packages/auth/tests/SessionAuthenticatorTest.php @@ -29,6 +29,7 @@ public function current_memoizes_the_resolved_authenticatable_for_the_current_se $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, sessionRegenerator: $this->createRegenerator($session), @@ -48,6 +49,7 @@ public function current_memoizes_a_missing_authenticatable_for_the_current_sessi $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, sessionRegenerator: $this->createRegenerator($session), @@ -70,6 +72,7 @@ public function current_re_resolves_when_the_session_identity_changes(): void $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, sessionRegenerator: $this->createRegenerator($session), @@ -97,6 +100,7 @@ public function reset_clears_the_cached_current_authenticatable(): void $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, sessionRegenerator: $this->createRegenerator($session), @@ -122,6 +126,7 @@ public function authenticate_replaces_a_cached_current_authenticatable(): void $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, sessionRegenerator: $this->createRegenerator($session), @@ -145,6 +150,7 @@ public function authenticate_regenerates_the_session_identifier(): void $sessionManager = new TestingSessionManager(); $authenticator = new SessionAuthenticator( + sessionManager: $sessionManager, session: $session, authenticatableResolver: new CountingAuthenticatableResolver(), sessionRegenerator: $this->createRegenerator($session, $sessionManager), @@ -154,6 +160,7 @@ public function authenticate_regenerates_the_session_identifier(): void $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)); } @@ -167,6 +174,7 @@ public function deauthenticate_regenerates_the_session_identifier_and_discards_t $sessionManager = new TestingSessionManager(); $authenticator = new SessionAuthenticator( + sessionManager: $sessionManager, session: $session, authenticatableResolver: new CountingAuthenticatableResolver(), sessionRegenerator: $this->createRegenerator($session, $sessionManager), @@ -176,6 +184,7 @@ public function deauthenticate_regenerates_the_session_identifier_and_discards_t $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')); diff --git a/packages/http/src/Session/SessionRegenerator.php b/packages/http/src/Session/SessionRegenerator.php index 9ee2afa123..6372a46d9d 100644 --- a/packages/http/src/Session/SessionRegenerator.php +++ b/packages/http/src/Session/SessionRegenerator.php @@ -19,27 +19,15 @@ public function __construct( ) {} /** - * Assigns a new ID to the current session, carrying over data. + * Assigns a new identifier to the current session, destroying the session it replaces. + * + * Session data is carried over. Callers are responsible for persisting the session + * afterwards, and for clearing its data first if it should not survive. */ public function regenerate(): void { $this->sessionManager->delete($this->session); $this->session->replaceId($this->sessionIdResolver->issueNewId()); - - $this->sessionManager->save($this->session); - } - - /** - * Assigns a new ID to the current session, discarding all data. - */ - public function invalidate(): void - { - $this->sessionManager->delete($this->session); - - $this->session->replaceId($this->sessionIdResolver->issueNewId()); - $this->session->clear(); - - $this->sessionManager->save($this->session); } } diff --git a/tests/Integration/Http/SessionRegeneratorTest.php b/tests/Integration/Http/SessionRegeneratorTest.php index 9518b6e099..97425da407 100644 --- a/tests/Integration/Http/SessionRegeneratorTest.php +++ b/tests/Integration/Http/SessionRegeneratorTest.php @@ -36,16 +36,6 @@ public function assigns_a_new_identifier_and_keeps_data(): void $this->assertSame('value', $this->session->get('key')); } - #[Test] - public function invalidate_discards_data(): void - { - $this->session->set('key', 'value'); - - $this->regenerator->invalidate(); - - $this->assertNull($this->session->get('key')); - } - #[Test] public function destroys_the_session_it_replaces(): void { From ef862daaab3f3aa6f95e8bb979485524984c8a87 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 12 Sep 2026 04:23:11 +0100 Subject: [PATCH 4/6] docs(auth): document session regeneration --- docs/2-features/04-authentication.md | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/2-features/04-authentication.md b/docs/2-features/04-authentication.md index 2e248a2f68..fe3c3b6542 100644 --- a/docs/2-features/04-authentication.md +++ b/docs/2-features/04-authentication.md @@ -90,6 +90,45 @@ 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. Inject {b`Tempest\Http\Session\SessionRegenerator`} for these transitions: + +```php app/Authentication/TwoFactorController.php +use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionManager; +use Tempest\Http\Session\SessionRegenerator; + +final readonly class TwoFactorController +{ + public function __construct( + private Session $session, + private SessionManager $sessionManager, + private SessionRegenerator $sessionRegenerator, + ) {} + + public function enable(): void + { + // Enable two-factor authentication for the current user... + + $this->sessionRegenerator->regenerate(); + $this->sessionManager->save($this->session); + } +} +``` + +`regenerate()` destroys the old session, assigns a new identifier, and carries the session data over. If the data must not survive the transition, clear the session before regenerating it: + +```php +$this->session->clear(); +$this->sessionRegenerator->regenerate(); +$this->sessionManager->save($this->session); +``` + +The new session must be saved after regeneration. The authenticator handles this automatically for normal authentication and deauthentication flows. + ### 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. From 90df7ea37c597e16f4b9626be83b44012ffbcde7 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sun, 13 Sep 2026 13:25:19 +0100 Subject: [PATCH 5/6] refactor(http)!: move regeneration onto the session manager --- docs/2-features/04-authentication.md | 14 ++-- .../AuthenticatorInitializer.php | 2 - .../Authentication/SessionAuthenticator.php | 10 +-- .../auth/tests/SessionAuthenticatorTest.php | 33 +++++----- .../Managers/DatabaseSessionManager.php | 11 ++++ .../Session/Managers/FileSessionManager.php | 11 ++++ .../Session/Managers/RedisSessionManager.php | 11 ++++ packages/http/src/Session/Session.php | 4 +- .../http/src/Session/SessionIdResolver.php | 2 +- packages/http/src/Session/SessionManager.php | 12 ++++ .../http/src/Session/SessionRegenerator.php | 33 ---------- .../SessionAuthenticatorTest.php | 2 + .../Http/CookieSessionIdResolverTest.php | 13 ++++ .../Integration/Http/DatabaseSessionTest.php | 20 ++++++ tests/Integration/Http/FileSessionTest.php | 22 +++++++ tests/Integration/Http/RedisSessionTest.php | 20 ++++++ .../Http/SessionCleanupStrategyTest.php | 2 + .../Http/SessionRegeneratorTest.php | 66 ------------------- tests/Integration/Route/RouterTest.php | 2 + 19 files changed, 151 insertions(+), 139 deletions(-) delete mode 100644 packages/http/src/Session/SessionRegenerator.php delete mode 100644 tests/Integration/Http/SessionRegeneratorTest.php diff --git a/docs/2-features/04-authentication.md b/docs/2-features/04-authentication.md index fe3c3b6542..c8c757c2c6 100644 --- a/docs/2-features/04-authentication.md +++ b/docs/2-features/04-authentication.md @@ -94,41 +94,35 @@ final readonly class AuthenticationController 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. Inject {b`Tempest\Http\Session\SessionRegenerator`} for these transitions: +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; -use Tempest\Http\Session\SessionRegenerator; final readonly class TwoFactorController { public function __construct( private Session $session, private SessionManager $sessionManager, - private SessionRegenerator $sessionRegenerator, ) {} public function enable(): void { // Enable two-factor authentication for the current user... - $this->sessionRegenerator->regenerate(); - $this->sessionManager->save($this->session); + $this->sessionManager->regenerate($this->session); } } ``` -`regenerate()` destroys the old session, assigns a new identifier, and carries the session data over. If the data must not survive the transition, clear the session before regenerating it: +`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->sessionRegenerator->regenerate(); -$this->sessionManager->save($this->session); +$this->sessionManager->regenerate($this->session); ``` -The new session must be saved after regeneration. The authenticator handles this automatically for normal authentication and deauthentication flows. - ### 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/AuthenticatorInitializer.php b/packages/auth/src/Authentication/AuthenticatorInitializer.php index 620bb2c19b..e87555096b 100644 --- a/packages/auth/src/Authentication/AuthenticatorInitializer.php +++ b/packages/auth/src/Authentication/AuthenticatorInitializer.php @@ -9,7 +9,6 @@ use Tempest\Container\Singleton; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionManager; -use Tempest\Http\Session\SessionRegenerator; final readonly class AuthenticatorInitializer implements Initializer { @@ -20,7 +19,6 @@ public function initialize(Container $container): Authenticator sessionManager: $container->get(SessionManager::class), session: $container->get(Session::class), authenticatableResolver: $container->get(AuthenticatableResolver::class), - sessionRegenerator: $container->get(SessionRegenerator::class), ); } } diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index 19da7e2528..24d41356b8 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -6,7 +6,6 @@ use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionManager; -use Tempest\Http\Session\SessionRegenerator; final class SessionAuthenticator implements Authenticator { @@ -24,7 +23,6 @@ public function __construct( private readonly SessionManager $sessionManager, private readonly Session $session, private readonly AuthenticatableResolver $authenticatableResolver, - private readonly SessionRegenerator $sessionRegenerator, ) {} public function authenticate(Authenticatable $authenticatable): void @@ -48,9 +46,7 @@ public function authenticate(Authenticatable $authenticatable): void // The session identifier must not survive a change in privilege level, or one // known to an attacker before authentication stays valid afterwards. - $this->sessionRegenerator->regenerate(); - - $this->sessionManager->save($this->session); + $this->sessionManager->regenerate($this->session); } public function deauthenticate(): void @@ -60,9 +56,7 @@ public function deauthenticate(): void // Discard session data so authenticated user data is not carried over // to the new identifier. $this->session->clear(); - $this->sessionRegenerator->regenerate(); - - $this->sessionManager->save($this->session); + $this->sessionManager->regenerate($this->session); } public function current(): ?Authenticatable diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php index e290421d37..172add2a7e 100644 --- a/packages/auth/tests/SessionAuthenticatorTest.php +++ b/packages/auth/tests/SessionAuthenticatorTest.php @@ -15,7 +15,6 @@ use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionIdResolver; use Tempest\Http\Session\SessionManager; -use Tempest\Http\Session\SessionRegenerator; final class SessionAuthenticatorTest extends TestCase { @@ -32,7 +31,6 @@ public function current_memoizes_the_resolved_authenticatable_for_the_current_se sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, - sessionRegenerator: $this->createRegenerator($session), ); $this->assertSame($authenticatable, $authenticator->current()); @@ -52,7 +50,6 @@ public function current_memoizes_a_missing_authenticatable_for_the_current_sessi sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, - sessionRegenerator: $this->createRegenerator($session), ); $this->assertNull($authenticator->current()); @@ -75,7 +72,6 @@ public function current_re_resolves_when_the_session_identity_changes(): void sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, - sessionRegenerator: $this->createRegenerator($session), ); $current = $authenticator->current(); @@ -103,7 +99,6 @@ public function reset_clears_the_cached_current_authenticatable(): void sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, - sessionRegenerator: $this->createRegenerator($session), ); $this->assertSame($authenticatable, $authenticator->current()); @@ -129,7 +124,6 @@ public function authenticate_replaces_a_cached_current_authenticatable(): void sessionManager: new TestingSessionManager(), session: $session, authenticatableResolver: $resolver, - sessionRegenerator: $this->createRegenerator($session), ); $current = $authenticator->current(); @@ -153,7 +147,6 @@ public function authenticate_regenerates_the_session_identifier(): void sessionManager: $sessionManager, session: $session, authenticatableResolver: new CountingAuthenticatableResolver(), - sessionRegenerator: $this->createRegenerator($session, $sessionManager), ); $authenticator->authenticate(new MemoizedAuthenticatable(id: 1)); @@ -177,7 +170,6 @@ public function deauthenticate_regenerates_the_session_identifier_and_discards_t sessionManager: $sessionManager, session: $session, authenticatableResolver: new CountingAuthenticatableResolver(), - sessionRegenerator: $this->createRegenerator($session, $sessionManager), ); $authenticator->deauthenticate(); @@ -190,15 +182,6 @@ public function deauthenticate_regenerates_the_session_identifier_and_discards_t $this->assertNull($session->get('key')); } - private function createRegenerator(Session $session, ?SessionManager $sessionManager = null): SessionRegenerator - { - return new SessionRegenerator( - sessionManager: $sessionManager ?? new TestingSessionManager(), - session: $session, - sessionIdResolver: new TestingSessionIdResolver(), - ); - } - private function createSession(): Session { $now = DateTime::now(); @@ -268,6 +251,13 @@ final class TestingSessionManager implements SessionManager public int $deletedSessions = 0; + private SessionIdResolver $sessionIdResolver; + + public function __construct() + { + $this->sessionIdResolver = new TestingSessionIdResolver(); + } + public function getOrCreate(SessionId $id): Session { $now = DateTime::now(); @@ -285,6 +275,15 @@ 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 { return true; 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/Session.php b/packages/http/src/Session/Session.php index 677485a66f..3b749ccaf1 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -133,8 +133,8 @@ public function cleanup(): void } /** - * @internal Prefer {@see SessionRegenerator}, which also destroys the session that is being - * replaced and sends the new identifier to the client. + * @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 { diff --git a/packages/http/src/Session/SessionIdResolver.php b/packages/http/src/Session/SessionIdResolver.php index aa52293593..782112e29d 100644 --- a/packages/http/src/Session/SessionIdResolver.php +++ b/packages/http/src/Session/SessionIdResolver.php @@ -14,7 +14,7 @@ public function resolve(): SessionId; /** * Creates a new identifier and sends it to the client, replacing the one it was using. * - * @see SessionRegenerator + * @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/http/src/Session/SessionRegenerator.php b/packages/http/src/Session/SessionRegenerator.php deleted file mode 100644 index 6372a46d9d..0000000000 --- a/packages/http/src/Session/SessionRegenerator.php +++ /dev/null @@ -1,33 +0,0 @@ -sessionManager->delete($this->session); - - $this->session->replaceId($this->sessionIdResolver->issueNewId()); - } -} 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/SessionRegeneratorTest.php b/tests/Integration/Http/SessionRegeneratorTest.php deleted file mode 100644 index 97425da407..0000000000 --- a/tests/Integration/Http/SessionRegeneratorTest.php +++ /dev/null @@ -1,66 +0,0 @@ - $this->container->get(Session::class); - } - - private SessionRegenerator $regenerator { - get => $this->container->get(SessionRegenerator::class); - } - - #[Test] - public function assigns_a_new_identifier_and_keeps_data(): void - { - $this->session->set('key', 'value'); - $previousId = (string) $this->session->id; - - $this->regenerator->regenerate(); - - $this->assertNotSame($previousId, (string) $this->session->id); - $this->assertSame('value', $this->session->get('key')); - } - - #[Test] - public function destroys_the_session_it_replaces(): void - { - $sessionManager = $this->container->get(SessionManager::class); - - $this->session->set('key', 'value'); - $previousId = $this->session->id; - - $this->regenerator->regenerate(); - - $previousSession = $sessionManager->getOrCreate($previousId); - - $this->assertNull($previousSession->get('key')); - } - - #[Test] - public function sends_the_new_identifier_to_the_client(): void - { - $cookies = $this->container->get(CookieManager::class); - - $this->regenerator->regenerate(); - - $this->assertSame( - (string) $this->session->id, - $cookies->get('tempest_session_id')?->value, - ); - } -} 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; From e407d5de3c12c545e07fe09fdfeaa711c7306b1b Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Mon, 14 Sep 2026 20:06:10 +0100 Subject: [PATCH 6/6] feat(upgrade): add session interface change rules --- .../config/sets/level/up-to-tempest-320.php | 18 ++++++ packages/upgrade/config/sets/tempest320.php | 15 +++++ .../upgrade/src/Set/TempestLevelSetList.php | 2 + packages/upgrade/src/Set/TempestSetList.php | 2 + ...SessionIdResolverImplementationsRector.php | 60 ++++++++++++++++++ ...ateSessionManagerImplementationsRector.php | 63 +++++++++++++++++++ .../AliasedSessionImplementations.input.php | 41 ++++++++++++ .../ExistingSessionImplementations.input.php | 50 +++++++++++++++ .../Fixtures/SessionImplementations.input.php | 41 ++++++++++++ .../tests/Tempest320/Tempest320RectorTest.php | 45 +++++++++++++ .../tests/Tempest320/tempest320_rector.php | 11 ++++ 11 files changed, 348 insertions(+) create mode 100644 packages/upgrade/config/sets/level/up-to-tempest-320.php create mode 100644 packages/upgrade/config/sets/tempest320.php create mode 100644 packages/upgrade/src/Tempest320/UpdateSessionIdResolverImplementationsRector.php create mode 100644 packages/upgrade/src/Tempest320/UpdateSessionManagerImplementationsRector.php create mode 100644 packages/upgrade/tests/Tempest320/Fixtures/AliasedSessionImplementations.input.php create mode 100644 packages/upgrade/tests/Tempest320/Fixtures/ExistingSessionImplementations.input.php create mode 100644 packages/upgrade/tests/Tempest320/Fixtures/SessionImplementations.input.php create mode 100644 packages/upgrade/tests/Tempest320/Tempest320RectorTest.php create mode 100644 packages/upgrade/tests/Tempest320/tempest320_rector.php 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);