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
33 changes: 33 additions & 0 deletions docs/2-features/04-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions packages/auth/src/Authentication/SessionAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 81 additions & 1 deletion packages/auth/tests/SessionAuthenticatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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
{
Expand Down
11 changes: 11 additions & 0 deletions packages/http/src/Session/Managers/DatabaseSessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +22,7 @@
public function __construct(
private Clock $clock,
private SessionConfig $config,
private SessionIdResolver $sessionIdResolver,
) {}

public function getOrCreate(SessionId $id): Session
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions packages/http/src/Session/Managers/FileSessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions packages/http/src/Session/Managers/RedisSessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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(
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 @@ -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());
}
}
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 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.
*/
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 SessionManager::regenerate()
*/
public function issueNewId(): SessionId;
Comment thread
osbre marked this conversation as resolved.
}
12 changes: 12 additions & 0 deletions packages/http/src/Session/SessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
osbre marked this conversation as resolved.

/**
* Determines whether the session is still valid.
*/
Expand Down
Loading
Loading