diff --git a/examples/index.php b/examples/index.php index 10f69392..50152096 100644 --- a/examples/index.php +++ b/examples/index.php @@ -23,8 +23,6 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Lcobucci\Clock\SystemClock; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key\InMemory; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -41,17 +39,14 @@ // simply run `php -S localhost:8888 index.php` // then point your browser at `http://localhost:8888/` -$sessionMiddleware = new SessionMiddleware( - Configuration::forSymmetricSigner( - new Sha256(), - InMemory::plainText('c9UA8QKLSmDEn4DhNeJIad/4JugZd/HvrjyKrS0jOes='), // // signature key (important: change this to your own) - ), +$sessionMiddleware = SessionMiddleware::fromSymmetricKeyDefaults( + InMemory::plainText('c9UA8QKLSmDEn4DhNeJIad/4JugZd/HvrjyKrS0jOes='), // // signature key (important: change this to your own) + 1200, // 20 minutes + SystemClock::fromUTC(), SetCookie::create('an-example-cookie-name') ->withSecure(false) // false on purpose, unless you have https locally ->withHttpOnly(true) ->withPath('/'), - 1200, // 20 minutes - new SystemClock(new DateTimeZone(date_default_timezone_get())), ); $myMiddleware = new class implements RequestHandlerInterface { diff --git a/src/Storageless/Http/SessionMiddleware.php b/src/Storageless/Http/SessionMiddleware.php index 64e9972c..c63e7379 100644 --- a/src/Storageless/Http/SessionMiddleware.php +++ b/src/Storageless/Http/SessionMiddleware.php @@ -22,31 +22,22 @@ use BadMethodCallException; use DateInterval; -use DateTimeZone; -use Dflydev\FigCookies\FigResponseCookies; use Dflydev\FigCookies\Modifier\SameSite; use Dflydev\FigCookies\SetCookie; use InvalidArgumentException; -use Lcobucci\Clock\Clock; use Lcobucci\Clock\SystemClock; use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Encoding\ChainedFormatter; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Token; -use Lcobucci\JWT\UnencryptedToken; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use OutOfBoundsException; +use Psr\Clock\ClockInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use PSR7Sessions\Storageless\Session\DefaultSessionData; +use PSR7Sessions\Storageless\Service\StoragelessSession; use PSR7Sessions\Storageless\Session\LazySession; use PSR7Sessions\Storageless\Session\SessionInterface; -use stdClass; -use function date_default_timezone_get; use function sprintf; final class SessionMiddleware implements MiddlewareInterface @@ -55,20 +46,15 @@ final class SessionMiddleware implements MiddlewareInterface public const SESSION_ATTRIBUTE = 'session'; public const DEFAULT_COOKIE = '__Secure-slsession'; public const DEFAULT_REFRESH_TIME = 60; - private Configuration $config; - private SetCookie $defaultCookie; - /** @param literal-string $sessionAttribute */ public function __construct( - Configuration $configuration, - SetCookie $defaultCookie, - private int $idleTimeout, - private Clock $clock, - private int $refreshTime = self::DEFAULT_REFRESH_TIME, - private string $sessionAttribute = self::SESSION_ATTRIBUTE, + private readonly StoragelessSession $sessionStorage, + private readonly Configuration $config, + private readonly int $idleTimeout, + private readonly int $refreshTime, + private readonly string $sessionAttribute, + private readonly ClockInterface $clock, ) { - $this->config = $configuration; - $this->defaultCookie = clone $defaultCookie; } /** @@ -76,14 +62,23 @@ public function __construct( */ public static function fromSymmetricKeyDefaults(Signer\Key $symmetricKey, int $idleTimeout): self { + $clock = SystemClock::fromUTC(); + return new self( + StoragelessSession::fromSymmetricKeyDefaults( + $symmetricKey, + $idleTimeout, + self::buildDefaultCookie(), + $clock, + ), Configuration::forSymmetricSigner( new Signer\Hmac\Sha256(), $symmetricKey, ), - self::buildDefaultCookie(), $idleTimeout, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + self::DEFAULT_REFRESH_TIME, + self::SESSION_ATTRIBUTE, + $clock, ); } @@ -96,15 +91,25 @@ public static function fromRsaAsymmetricKeyDefaults( Signer\Key $publicRsaKey, int $idleTimeout, ): self { + $clock = SystemClock::fromUTC(); + return new self( + StoragelessSession::fromRsaAsymmetricKeyDefaults( + $privateRsaKey, + $publicRsaKey, + $idleTimeout, + self::buildDefaultCookie(), + $clock, + ), Configuration::forAsymmetricSigner( new Signer\Rsa\Sha256(), $privateRsaKey, $publicRsaKey, ), - self::buildDefaultCookie(), $idleTimeout, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + self::DEFAULT_REFRESH_TIME, + self::SESSION_ATTRIBUTE, + $clock, ); } @@ -125,91 +130,28 @@ public static function buildDefaultCookie(): SetCookie */ public function process(Request $request, RequestHandlerInterface $handler): Response { - $token = $this->parseToken($request); - $sessionContainer = LazySession::fromContainerBuildingCallback(function () use ($token): SessionInterface { - return $this->extractSessionContainer($token); - }); + $session = LazySession::fromContainerBuildingCallback( + fn (): SessionInterface => $this->sessionStorage->get($request) + ); return $this->appendToken( - $sessionContainer, - $handler->handle($request->withAttribute($this->sessionAttribute, $sessionContainer)), - $token, + $session, + $handler->handle($request->withAttribute($this->sessionAttribute, $session)), + $this->sessionStorage->cookieToToken($this->sessionStorage->getCookieFromMessage($request)), ); } - /** - * Extract the token from the given request object - */ - private function parseToken(Request $request): UnencryptedToken|null - { - /** @var array $cookies */ - $cookies = $request->getCookieParams(); - $cookieName = $this->defaultCookie->getName(); - - if (! isset($cookies[$cookieName])) { - return null; - } - - $cookie = $cookies[$cookieName]; - if ($cookie === '') { - return null; - } - - try { - $token = $this->config->parser()->parse($cookie); - } catch (InvalidArgumentException) { - return null; - } - - if (! $token instanceof UnencryptedToken) { - return null; - } - - $constraints = [ - new StrictValidAt($this->clock), - new SignedWith($this->config->signer(), $this->config->verificationKey()), - ]; - - if (! $this->config->validator()->validate($token, ...$constraints)) { - return null; - } - - return $token; - } - - /** @throws OutOfBoundsException */ - private function extractSessionContainer(UnencryptedToken|null $token): SessionInterface - { - if (! $token) { - return DefaultSessionData::newEmptySession(); - } - - try { - return DefaultSessionData::fromDecodedTokenData( - (object) $token->claims()->get(self::SESSION_CLAIM, new stdClass()), - ); - } catch (BadMethodCallException) { - return DefaultSessionData::newEmptySession(); - } - } - /** * @throws BadMethodCallException * @throws InvalidArgumentException */ private function appendToken(SessionInterface $sessionContainer, Response $response, Token|null $token): Response { - $sessionContainerChanged = $sessionContainer->hasChanged(); - - if ($sessionContainerChanged && $sessionContainer->isEmpty()) { - return FigResponseCookies::set($response, $this->getExpirationCookie()); - } - - if ($sessionContainerChanged || $this->shouldTokenBeRefreshed($token)) { - return FigResponseCookies::set($response, $this->getTokenCookie($sessionContainer)); + if ($sessionContainer->hasChanged() === false && $this->shouldTokenBeRefreshed($token) === false) { + return $response; } - return $response; + return $this->sessionStorage->withSession($response, $sessionContainer); } private function shouldTokenBeRefreshed(Token|null $token): bool @@ -224,36 +166,4 @@ private function shouldTokenBeRefreshed(Token|null $token): bool ->sub(new DateInterval(sprintf('PT%sS', $this->refreshTime))), ); } - - /** @throws BadMethodCallException */ - private function getTokenCookie(SessionInterface $sessionContainer): SetCookie - { - $now = $this->clock->now(); - $expiresAt = $now->add(new DateInterval(sprintf('PT%sS', $this->idleTimeout))); - - return $this - ->defaultCookie - ->withValue( - $this->config->builder(ChainedFormatter::withUnixTimestampDates()) - ->issuedAt($now) - ->canOnlyBeUsedAfter($now) - ->expiresAt($expiresAt) - ->withClaim(self::SESSION_CLAIM, $sessionContainer) - ->getToken($this->config->signer(), $this->config->signingKey()) - ->toString(), - ) - ->withExpires($expiresAt); - } - - private function getExpirationCookie(): SetCookie - { - return $this - ->defaultCookie - ->withValue(null) - ->withExpires( - $this->clock - ->now() - ->modify('-30 days'), - ); - } } diff --git a/test/StoragelessTest/Http/SessionMiddlewareTest.php b/test/StoragelessTest/Http/SessionMiddlewareTest.php index 5dbb6abb..7205c74c 100644 --- a/test/StoragelessTest/Http/SessionMiddlewareTest.php +++ b/test/StoragelessTest/Http/SessionMiddlewareTest.php @@ -22,7 +22,6 @@ use DateTime; use DateTimeImmutable; -use DateTimeZone; use Dflydev\FigCookies\FigResponseCookies; use Dflydev\FigCookies\Modifier\SameSite; use Dflydev\FigCookies\SetCookie; @@ -45,6 +44,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use PSR7Sessions\Storageless\Http\SessionMiddleware; +use PSR7Sessions\Storageless\Service\StoragelessSession; use PSR7Sessions\Storageless\Session\DefaultSessionData; use PSR7Sessions\Storageless\Session\SessionInterface; use PSR7SessionsTest\Storageless\Asset\MutableBadCookie; @@ -52,7 +52,6 @@ use function assert; use function base64_encode; -use function date_default_timezone_get; use function random_bytes; use function random_int; use function time; @@ -192,13 +191,6 @@ static function (ServerRequestInterface $request) use ($sessionValue) { }, ); - $this->createTokenWithCustomClaim( - $middleware, - new DateTimeImmutable('-1 day'), - new DateTimeImmutable('+1 day'), - 'not valid session data', - ); - $middleware->process( (new ServerRequest()) ->withCookieParams([ @@ -246,14 +238,24 @@ public function testWillIgnoreRequestsWithNonPlainTokens(): void ->method('parse') ->with('THE_COOKIE') ->willReturn($unknownTokenType); + $configuration->setParser($fakeParser); + $clock = new FrozenClock(new DateTimeImmutable()); + $cookie = SetCookie::create('COOKIE_NAME'); $this->ensureSameResponse( new SessionMiddleware( + new StoragelessSession( + $configuration, + 100, + $cookie, + $clock, + ), $configuration, - SetCookie::create('COOKIE_NAME'), 100, - new FrozenClock(new DateTimeImmutable()), + SessionMiddleware::DEFAULT_REFRESH_TIME, + SessionMiddleware::SESSION_ATTRIBUTE, + $clock, ), (new ServerRequest()) ->withCookieParams(['COOKIE_NAME' => 'THE_COOKIE']), @@ -346,22 +348,28 @@ public function testWillIgnoreRequestsWithEmptyStringCookie(callable $middleware public function testWillRefreshTokenWithIssuedAtExactlyAtTokenRefreshTimeThreshold(): void { // forcing ourselves to think of time as a mutable value: - $time = time() + random_int(-100, +100); - $now = new DateTimeImmutable('@' . $time); - $clock = new FrozenClock($now); - $key = self::makeRandomSymmetricKey(); - + $time = time() + random_int(-100, +100); + $now = new DateTimeImmutable('@' . $time); + $clock = new FrozenClock($now); + $key = self::makeRandomSymmetricKey(); $configuration = Configuration::forAsymmetricSigner( new Sha256(), $key, $key, ); - $middleware = new SessionMiddleware( + + $middleware = new SessionMiddleware( + new StoragelessSession( + $configuration, + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + $clock, + ), $configuration, - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 1000, - $clock, 100, + SessionMiddleware::SESSION_ATTRIBUTE, + $clock, ); $requestWithTokenIssuedInThePast = (new ServerRequest()) @@ -375,10 +383,15 @@ public function testWillRefreshTokenWithIssuedAtExactlyAtTokenRefreshTimeThresho ->toString(), ]); + $response = $middleware->process( + $requestWithTokenIssuedInThePast, + $this->fakeDelegate( + static fn () => new Response() + ), + ); + $tokenString = $this - ->getCookie($middleware->process($requestWithTokenIssuedInThePast, $this->fakeDelegate(static function () { - return new Response(); - }))) + ->getCookie($response) ->getValue(); self::assertIsString($tokenString); @@ -390,17 +403,28 @@ public function testWillRefreshTokenWithIssuedAtExactlyAtTokenRefreshTimeThresho public function testWillNotRefreshATokenForARequestWithNoGivenTokenAndNoSessionModification(): void { + $clock = new FrozenClock(new DateTimeImmutable()); $key = self::makeRandomSymmetricKey(); $middleware = new SessionMiddleware( + new StoragelessSession( + Configuration::forAsymmetricSigner( + new Sha256(), + $key, + $key, + ), + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + $clock, + ), Configuration::forAsymmetricSigner( new Sha256(), $key, $key, ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 1000, - new FrozenClock(new DateTimeImmutable()), 100, + SessionMiddleware::SESSION_ATTRIBUTE, + $clock, ); self::assertNull( @@ -452,10 +476,14 @@ static function (ServerRequestInterface $request) { public function testWillSendExpirationCookieWhenSessionContentsAreCleared(callable $middlewareFactory): void { $middleware = $middlewareFactory(); + $this->ensureClearsSessionCookie( $middleware, $this->requestWithResponseCookies( - $middleware->process(new ServerRequest(), $this->writingMiddleware()), + $middleware->process( + new ServerRequest(), + $this->writingMiddleware(), + ), ), $this->fakeDelegate( static function (ServerRequestInterface $request) { @@ -488,22 +516,42 @@ public function testWillIgnoreMalformedTokens(callable $middlewareFactory): void public function testRejectsTokensWithInvalidSignature(): void { $middleware = new SessionMiddleware( + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ), + 100, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), + ), Configuration::forSymmetricSigner( new Sha256(), self::makeRandomSymmetricKey(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 100, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SessionMiddleware::DEFAULT_REFRESH_TIME, + SessionMiddleware::SESSION_ATTRIBUTE, + SystemClock::fromUTC(), ); $middlewareWithAlteredKey = new SessionMiddleware( + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ), + 100, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), + ), Configuration::forSymmetricSigner( new Sha256(), self::makeRandomSymmetricKey(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 100, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SessionMiddleware::DEFAULT_REFRESH_TIME, + SessionMiddleware::SESSION_ATTRIBUTE, + SystemClock::fromUTC(), ); $this->ensureSameResponse( @@ -524,16 +572,27 @@ public function testAllowsModifyingCookieDetails(): void ->withMaxAge(123123) ->withValue('a-random-value'); - $dateTime = new DateTimeImmutable(); + $dateTime = new DateTimeImmutable(); + $clock = new FrozenClock($dateTime); + $middleware = new SessionMiddleware( + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ), + 123456, + $defaultCookie, + $clock, + ), Configuration::forSymmetricSigner( new Sha256(), self::makeRandomSymmetricKey(), ), - $defaultCookie, 123456, - new FrozenClock($dateTime), 123, + SessionMiddleware::SESSION_ATTRIBUTE, + $clock, ); $response = $middleware->process(new ServerRequest(), $this->writingMiddleware()); @@ -559,15 +618,25 @@ public function testSessionTokenParsingIsDelayedWhenSessionIsNotBeingUsed(): voi $signer->expects(self::never())->method('verify'); $signer->method('algorithmId')->willReturn('HS256'); - $currentTimeProvider = new SystemClock(new DateTimeZone(date_default_timezone_get())); + $currentTimeProvider = SystemClock::fromUTC(); $setCookie = SetCookie::create(SessionMiddleware::DEFAULT_COOKIE); $middleware = new SessionMiddleware( + new StoragelessSession( + Configuration::forSymmetricSigner( + $signer, + $key, + ), + 100, + $setCookie, + $currentTimeProvider, + ), Configuration::forSymmetricSigner( $signer, $key, ), - $setCookie, 100, + SessionMiddleware::DEFAULT_REFRESH_TIME, + SessionMiddleware::SESSION_ATTRIBUTE, $currentTimeProvider, ); $configurationForBuiler = Configuration::forSymmetricSigner( @@ -604,11 +673,17 @@ public function testShouldRegenerateTokenWhenRequestHasATokenThatIsAboutToExpire self::makeRandomSymmetricKey(), ); $middleware = new SessionMiddleware( + new StoragelessSession( + $configuration, + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + new FrozenClock($dateTime), + ), $configuration, - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 1000, - new FrozenClock($dateTime), 300, + SessionMiddleware::SESSION_ATTRIBUTE, + new FrozenClock($dateTime), ); $expiringToken = (new ServerRequest()) @@ -643,11 +718,17 @@ public function testShouldNotRegenerateTokenWhenRequestHasATokenThatIsFarFromExp self::makeRandomSymmetricKey(), ); $middleware = new SessionMiddleware( + new StoragelessSession( + $configuration, + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), + ), $configuration, - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 1000, - new SystemClock(new DateTimeZone(date_default_timezone_get())), 300, + SessionMiddleware::SESSION_ATTRIBUTE, + SystemClock::fromUTC(), ); $validToken = (new ServerRequest()) @@ -670,14 +751,24 @@ public function validMiddlewaresProvider(): array return $this->defaultMiddlewaresProvider() + [ 'from-constructor' => [ static function (): SessionMiddleware { + $cookie = SetCookie::create(SessionMiddleware::DEFAULT_COOKIE); + $configuration = Configuration::forSymmetricSigner( + new Signer\Hmac\Sha512(), + self::makeRandomSymmetricKey(), + ); + return new SessionMiddleware( - Configuration::forSymmetricSigner( - new Signer\Hmac\Sha512(), - self::makeRandomSymmetricKey(), + new StoragelessSession( + $configuration, + 100, + $cookie, + SystemClock::fromUTC(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + $configuration, 100, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SessionMiddleware::DEFAULT_REFRESH_TIME, + SessionMiddleware::SESSION_ATTRIBUTE, + SystemClock::fromUTC(), ); }, ], @@ -693,6 +784,7 @@ static function (): SessionMiddleware { return SessionMiddleware::fromSymmetricKeyDefaults( self::makeRandomSymmetricKey(), 100, + SystemClock::fromUTC(), ); }, ], @@ -702,6 +794,7 @@ static function (): SessionMiddleware { Signer\Key\InMemory::file(__DIR__ . '/../../keys/private_key.pem'), Signer\Key\InMemory::file(__DIR__ . '/../../keys/public_key.pem'), 200, + SystemClock::fromUTC(), ); }, ], @@ -719,10 +812,17 @@ public function testMutableCookieWillNotBeUsed(): void self::makeRandomSymmetricKey(), ); $middleware = new SessionMiddleware( + new StoragelessSession( + $configuration, + 1000, + $cookie, + SystemClock::fromUTC(), + ), $configuration, - $cookie, 1000, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SessionMiddleware::DEFAULT_REFRESH_TIME, + SessionMiddleware::SESSION_ATTRIBUTE, + SystemClock::fromUTC(), ); $cookie->mutated = true; @@ -740,16 +840,23 @@ public function testAllowCustomRequestAttributeName(): void $customAttributeName = 'my_custom_session_attribute_name'; self::assertNotEmpty($customAttributeName); + $configuration = Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ); + $middleware = new SessionMiddleware( - Configuration::forSymmetricSigner( - new Sha256(), - self::makeRandomSymmetricKey(), + new StoragelessSession( + $configuration, + 100, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + $configuration, 100, - SystemClock::fromSystemTimezone(), 100, $customAttributeName, + SystemClock::fromUTC(), ); $middleware->process( @@ -876,9 +983,9 @@ private function fakeDelegate(callable $callback): RequestHandlerInterface $middleware ->expects(self::once()) - ->method('handle') - ->willReturnCallback($callback) - ->with(self::isInstanceOf(RequestInterface::class)); + ->method('handle') + ->willReturnCallback($callback) + ->with(self::isInstanceOf(RequestInterface::class)); return $middleware; }