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
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@
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
{
#[Singleton]
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),
);
}
}
14 changes: 9 additions & 5 deletions packages/auth/src/Authentication/SessionAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand All @@ -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->invalidate();
}

public function current(): ?Authenticatable
Expand Down
84 changes: 78 additions & 6 deletions 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 @@ -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());
Expand All @@ -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());
Expand All @@ -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();
Expand All @@ -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());
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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 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 +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
{
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;
}
45 changes: 45 additions & 0 deletions packages/http/src/Session/SessionRegenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?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 ID to the current session, carrying over data.
*/
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);
}
}
Loading
Loading