Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/2-features/04-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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
{
Expand All @@ -19,6 +20,7 @@ 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),
);
}
}
15 changes: 13 additions & 2 deletions packages/auth/src/Authentication/SessionAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionManager;
use Tempest\Http\Session\SessionRegenerator;

final class SessionAuthenticator implements Authenticator
{
Expand All @@ -23,6 +24,7 @@ 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
Expand All @@ -43,14 +45,23 @@ 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();

$this->sessionManager->save($this->session);
}

public function deauthenticate(): void
{
$this->session->remove(self::AUTHENTICATABLE_KEY);
$this->session->remove(self::AUTHENTICATABLE_CLASS);
$this->clearCurrent();

// 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);
}

Expand Down
83 changes: 82 additions & 1 deletion packages/auth/tests/SessionAuthenticatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -30,6 +32,7 @@ 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());
Expand All @@ -49,6 +52,7 @@ 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());
Expand All @@ -71,6 +75,7 @@ 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();
Expand Down Expand Up @@ -98,6 +103,7 @@ 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());
Expand All @@ -123,6 +129,7 @@ public function authenticate_replaces_a_cached_current_authenticatable(): void
sessionManager: new TestingSessionManager(),
session: $session,
authenticatableResolver: $resolver,
sessionRegenerator: $this->createRegenerator($session),
);

$current = $authenticator->current();
Expand All @@ -136,6 +143,62 @@ 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(),
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, $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(),
sessionRegenerator: $this->createRegenerator($session, $sessionManager),
);

$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 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();
Expand Down Expand Up @@ -186,10 +249,25 @@ 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;

public function getOrCreate(SessionId $id): Session
{
$now = DateTime::now();
Expand All @@ -202,7 +280,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
{
Expand Down
44 changes: 27 additions & 17 deletions packages/http/src/Session/Resolvers/CookieSessionIdResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ public function resolve(): SessionId
id: $this->request->headers[$sessionKey] ?? Uuid::v4()->toString(),
);
}

public function issueNewId(): SessionId
{
return new SessionId(id: Uuid::v4()->toString());
}
}
9 changes: 9 additions & 0 deletions packages/http/src/Session/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ 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.
*/
public function replaceId(SessionId $id): void
{
$this->id = $id;
}

/**
* Clears all values from the session.
*/
Expand Down
10 changes: 10 additions & 0 deletions packages/http/src/Session/SessionIdResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 issueNewId(): SessionId;
}
33 changes: 33 additions & 0 deletions packages/http/src/Session/SessionRegenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Session;

/**
* Regenerates the session identifier to prevent session fixation attacks.
*
* @see https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
* @see https://owasp.org/www-community/attacks/Session_fixation
*/
final readonly class SessionRegenerator
{
public function __construct(
private SessionManager $sessionManager,
private Session $session,
private SessionIdResolver $sessionIdResolver,
) {}

/**
* 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());
}
}
Loading
Loading