diff --git a/examples/index.php b/examples/index.php index 10f69392..baa983d7 100644 --- a/examples/index.php +++ b/examples/index.php @@ -30,6 +30,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\SessionInterface; require_once __DIR__ . '/../vendor/autoload.php'; @@ -41,17 +42,22 @@ // simply run `php -S localhost:8888 index.php` // then point your browser at `http://localhost:8888/` +$clock = SystemClock::fromUTC(); $sessionMiddleware = new SessionMiddleware( - Configuration::forSymmetricSigner( - new Sha256(), - InMemory::plainText('c9UA8QKLSmDEn4DhNeJIad/4JugZd/HvrjyKrS0jOes='), // // signature key (important: change this to your own) + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + InMemory::plainText('c9UA8QKLSmDEn4DhNeJIad/4JugZd/HvrjyKrS0jOes='), // signature key (important: change this to your own) + ), + 1200, + SetCookie::create('an-example-cookie-name') + ->withSecure(false) // false on purpose, unless you have https locally + ->withHttpOnly(true) + ->withPath('/'), + $clock, ), - 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())), + 1200, // 20 minutes, + $clock, ); $myMiddleware = new class implements RequestHandlerInterface { diff --git a/src/Storageless/Http/SessionMiddleware.php b/src/Storageless/Http/SessionMiddleware.php index 64e9972c..3dc1998d 100644 --- a/src/Storageless/Http/SessionMiddleware.php +++ b/src/Storageless/Http/SessionMiddleware.php @@ -22,31 +22,21 @@ 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 +45,14 @@ 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 int $idleTimeout, + private readonly ClockInterface $clock, + private readonly int $refreshTime = self::DEFAULT_REFRESH_TIME, + private readonly string $sessionAttribute = self::SESSION_ATTRIBUTE, ) { - $this->config = $configuration; - $this->defaultCookie = clone $defaultCookie; } /** @@ -76,14 +60,17 @@ public function __construct( */ public static function fromSymmetricKeyDefaults(Signer\Key $symmetricKey, int $idleTimeout): self { + $clock = SystemClock::fromUTC(); + return new self( - Configuration::forSymmetricSigner( - new Signer\Hmac\Sha256(), + StoragelessSession::fromSymmetricKeyDefaults( $symmetricKey, + $idleTimeout, + self::buildDefaultCookie(), + $clock, ), - self::buildDefaultCookie(), $idleTimeout, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + $clock, ); } @@ -96,15 +83,18 @@ public static function fromRsaAsymmetricKeyDefaults( Signer\Key $publicRsaKey, int $idleTimeout, ): self { + $clock = SystemClock::fromUTC(); + return new self( - Configuration::forAsymmetricSigner( - new Signer\Rsa\Sha256(), + StoragelessSession::fromRsaAsymmetricKeyDefaults( $privateRsaKey, $publicRsaKey, + $idleTimeout, + self::buildDefaultCookie(), + $clock, ), - self::buildDefaultCookie(), $idleTimeout, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + $clock, ); } @@ -125,91 +115,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 ($sessionContainer->hasChanged() === false && $this->shouldTokenBeRefreshed($token) === false) { + return $response; } - if ($sessionContainerChanged || $this->shouldTokenBeRefreshed($token)) { - return FigResponseCookies::set($response, $this->getTokenCookie($sessionContainer)); - } - - return $response; + return $this->sessionStorage->withSession($response, $sessionContainer); } private function shouldTokenBeRefreshed(Token|null $token): bool @@ -224,36 +151,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..633cb38a 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,8 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use PSR7Sessions\Storageless\Http\SessionMiddleware; +use PSR7Sessions\Storageless\Service\SessionStorage; +use PSR7Sessions\Storageless\Service\StoragelessSession; use PSR7Sessions\Storageless\Session\DefaultSessionData; use PSR7Sessions\Storageless\Session\SessionInterface; use PSR7SessionsTest\Storageless\Asset\MutableBadCookie; @@ -52,7 +53,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 +192,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([ @@ -247,13 +240,18 @@ public function testWillIgnoreRequestsWithNonPlainTokens(): void ->with('THE_COOKIE') ->willReturn($unknownTokenType); $configuration->setParser($fakeParser); + $clock = new FrozenClock(new DateTimeImmutable()); $this->ensureSameResponse( new SessionMiddleware( - $configuration, - SetCookie::create('COOKIE_NAME'), + new StoragelessSession( + $configuration, + 100, + SetCookie::create('COOKIE_NAME'), + $clock, + ), 100, - new FrozenClock(new DateTimeImmutable()), + $clock, ), (new ServerRequest()) ->withCookieParams(['COOKIE_NAME' => 'THE_COOKIE']), @@ -357,8 +355,12 @@ public function testWillRefreshTokenWithIssuedAtExactlyAtTokenRefreshTimeThresho $key, ); $middleware = new SessionMiddleware( - $configuration, - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + new StoragelessSession( + $configuration, + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + $clock, + ), 1000, $clock, 100, @@ -375,10 +377,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,16 +397,21 @@ public function testWillRefreshTokenWithIssuedAtExactlyAtTokenRefreshTimeThresho public function testWillNotRefreshATokenForARequestWithNoGivenTokenAndNoSessionModification(): void { + $clock = new FrozenClock(new DateTimeImmutable()); $key = self::makeRandomSymmetricKey(); $middleware = new SessionMiddleware( - Configuration::forAsymmetricSigner( - new Sha256(), - $key, - $key, + new StoragelessSession( + Configuration::forAsymmetricSigner( + new Sha256(), + $key, + $key, + ), + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + $clock, ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 1000, - new FrozenClock(new DateTimeImmutable()), + $clock, 100, ); @@ -488,22 +500,30 @@ public function testWillIgnoreMalformedTokens(callable $middlewareFactory): void public function testRejectsTokensWithInvalidSignature(): void { $middleware = new SessionMiddleware( - Configuration::forSymmetricSigner( - new Sha256(), - self::makeRandomSymmetricKey(), + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ), + 100, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 100, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SystemClock::fromUTC(), ); $middlewareWithAlteredKey = new SessionMiddleware( - Configuration::forSymmetricSigner( - new Sha256(), - self::makeRandomSymmetricKey(), + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ), + 100, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 100, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SystemClock::fromUTC(), ); $this->ensureSameResponse( @@ -525,14 +545,19 @@ public function testAllowsModifyingCookieDetails(): void ->withValue('a-random-value'); $dateTime = new DateTimeImmutable(); + $clock = new FrozenClock($dateTime); $middleware = new SessionMiddleware( - Configuration::forSymmetricSigner( - new Sha256(), - self::makeRandomSymmetricKey(), + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ), + 123456, + $defaultCookie, + $clock, ), - $defaultCookie, 123456, - new FrozenClock($dateTime), + $clock, 123, ); @@ -559,14 +584,18 @@ 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( - Configuration::forSymmetricSigner( - $signer, - $key, + new StoragelessSession( + Configuration::forSymmetricSigner( + $signer, + $key, + ), + 100, + $setCookie, + $currentTimeProvider, ), - $setCookie, 100, $currentTimeProvider, ); @@ -604,8 +633,12 @@ public function testShouldRegenerateTokenWhenRequestHasATokenThatIsAboutToExpire self::makeRandomSymmetricKey(), ); $middleware = new SessionMiddleware( - $configuration, - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + new StoragelessSession( + $configuration, + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + new FrozenClock($dateTime), + ), 1000, new FrozenClock($dateTime), 300, @@ -643,10 +676,14 @@ public function testShouldNotRegenerateTokenWhenRequestHasATokenThatIsFarFromExp self::makeRandomSymmetricKey(), ); $middleware = new SessionMiddleware( - $configuration, - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + new StoragelessSession( + $configuration, + 1000, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), + ), 1000, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SystemClock::fromUTC(), 300, ); @@ -671,13 +708,17 @@ public function validMiddlewaresProvider(): array 'from-constructor' => [ static function (): SessionMiddleware { return new SessionMiddleware( - Configuration::forSymmetricSigner( - new Signer\Hmac\Sha512(), - self::makeRandomSymmetricKey(), + new StoragelessSession( + Configuration::forSymmetricSigner( + new Signer\Hmac\Sha512(), + self::makeRandomSymmetricKey(), + ), + 100, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 100, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SystemClock::fromUTC(), ); }, ], @@ -719,10 +760,14 @@ public function testMutableCookieWillNotBeUsed(): void self::makeRandomSymmetricKey(), ); $middleware = new SessionMiddleware( - $configuration, - $cookie, + new StoragelessSession( + $configuration, + 1000, + $cookie, + SystemClock::fromUTC(), + ), 1000, - new SystemClock(new DateTimeZone(date_default_timezone_get())), + SystemClock::fromUTC(), ); $cookie->mutated = true; @@ -741,13 +786,17 @@ public function testAllowCustomRequestAttributeName(): void self::assertNotEmpty($customAttributeName); $middleware = new SessionMiddleware( - Configuration::forSymmetricSigner( - new Sha256(), - self::makeRandomSymmetricKey(), + new StoragelessSession( + Configuration::forSymmetricSigner( + new Sha256(), + self::makeRandomSymmetricKey(), + ), + 100, + SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), + SystemClock::fromUTC(), ), - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), 100, - SystemClock::fromSystemTimezone(), + SystemClock::fromUTC(), 100, $customAttributeName, ); @@ -876,9 +925,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; } @@ -898,9 +947,14 @@ private function getCookie(ResponseInterface $response, string $name = SessionMi private function getJwtConfiguration(SessionMiddleware $middleware): Configuration { - $property = new ReflectionProperty(SessionMiddleware::class, 'config'); + $sessionStorageProperty = new ReflectionProperty(SessionMiddleware::class, 'sessionStorage'); + + $sessionStorage = $sessionStorageProperty->getValue($middleware); + assert($sessionStorage instanceof SessionStorage); + + $property = new ReflectionProperty(StoragelessSession::class, 'configuration'); - $config = $property->getValue($middleware); + $config = $property->getValue($sessionStorage); assert($config instanceof Configuration);