diff --git a/examples/index.php b/examples/index.php index 9522aa9e..1c3f813f 100644 --- a/examples/index.php +++ b/examples/index.php @@ -41,6 +41,7 @@ // simply run `php -S localhost:9999 index.php` // then point your browser at `http://localhost:9999/` +$clock = SystemClock::fromUTC(); $sessionMiddleware = new SessionMiddleware( (new Configuration( JwtConfig::forSymmetricSigner( diff --git a/src/Storageless/Http/SessionMiddleware.php b/src/Storageless/Http/SessionMiddleware.php index abd8b3d0..2b38202a 100644 --- a/src/Storageless/Http/SessionMiddleware.php +++ b/src/Storageless/Http/SessionMiddleware.php @@ -21,27 +21,13 @@ namespace PSR7Sessions\Storageless\Http; use BadMethodCallException; -use DateInterval; -use Dflydev\FigCookies\FigResponseCookies; -use Dflydev\FigCookies\SetCookie; use InvalidArgumentException; -use Lcobucci\JWT\Encoding\ChainedFormatter; -use Lcobucci\JWT\Token; -use Lcobucci\JWT\UnencryptedToken; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use OutOfBoundsException; 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\Http\ClientFingerprint\SameOriginRequest; -use PSR7Sessions\Storageless\Session\DefaultSessionData; +use PSR7Sessions\Storageless\Service\StoragelessSession; use PSR7Sessions\Storageless\Session\LazySession; -use PSR7Sessions\Storageless\Session\SessionInterface; -use stdClass; - -use function sprintf; /** @immutable */ final class SessionMiddleware implements MiddlewareInterface @@ -49,9 +35,12 @@ final class SessionMiddleware implements MiddlewareInterface public const SESSION_CLAIM = 'session-data'; public const SESSION_ATTRIBUTE = 'session'; + private readonly StoragelessSession $service; + public function __construct( private readonly Configuration $config, ) { + $this->service = new StoragelessSession($config); } /** @@ -62,149 +51,11 @@ public function __construct( */ public function process(Request $request, RequestHandlerInterface $handler): Response { - $sameOriginRequest = new SameOriginRequest($this->config->getClientFingerprintConfiguration(), $request); - $token = $this->parseToken($request, $sameOriginRequest); - $sessionContainer = LazySession::fromContainerBuildingCallback(function () use ($token): SessionInterface { - return $this->extractSessionContainer($token); - }); - - return $this->appendToken( - $sessionContainer, - $handler->handle($request->withAttribute($this->config->getSessionAttribute(), $sessionContainer)), - $token, - $sameOriginRequest, + return $this->service->appendSession( + LazySession::fromToken($this->service->requestToToken($request)), + $request, + null, + $handler, ); } - - /** - * Extract the token from the given request object - */ - private function parseToken(Request $request, SameOriginRequest $sameOriginRequest): UnencryptedToken|null - { - /** @var array $cookies */ - $cookies = $request->getCookieParams(); - $cookieName = $this->config->getCookie()->getName(); - - if (! isset($cookies[$cookieName])) { - return null; - } - - $cookie = $cookies[$cookieName]; - if ($cookie === '') { - return null; - } - - $jwtConfiguration = $this->config->getJwtConfiguration(); - try { - $token = $jwtConfiguration->parser()->parse($cookie); - } catch (InvalidArgumentException) { - return null; - } - - if (! $token instanceof UnencryptedToken) { - return null; - } - - $constraints = [ - new StrictValidAt($this->config->getClock()), - new SignedWith($jwtConfiguration->signer(), $jwtConfiguration->verificationKey()), - $sameOriginRequest, - ]; - - if (! $jwtConfiguration->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, - SameOriginRequest $sameOriginRequest, - ): 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, $sameOriginRequest)); - } - - return $response; - } - - private function shouldTokenBeRefreshed(Token|null $token): bool - { - if ($token === null) { - return false; - } - - return $token->hasBeenIssuedBefore( - $this->config->getClock() - ->now() - ->sub(new DateInterval(sprintf('PT%sS', $this->config->getRefreshTime()))), - ); - } - - /** @throws BadMethodCallException */ - private function getTokenCookie(SessionInterface $sessionContainer, SameOriginRequest $sameOriginRequest): SetCookie - { - $now = $this->config->getClock()->now(); - $expiresAt = $now->add(new DateInterval(sprintf('PT%sS', $this->config->getIdleTimeout()))); - - $jwtConfiguration = $this->config->getJwtConfiguration(); - - $builder = $jwtConfiguration->builder(ChainedFormatter::withUnixTimestampDates()) - ->issuedAt($now) - ->canOnlyBeUsedAfter($now) - ->expiresAt($expiresAt) - ->withClaim(self::SESSION_CLAIM, $sessionContainer); - - $builder = $sameOriginRequest->configure($builder); - - return $this - ->config->getCookie() - ->withValue( - $builder - ->getToken($jwtConfiguration->signer(), $jwtConfiguration->signingKey()) - ->toString(), - ) - ->withExpires($expiresAt); - } - - private function getExpirationCookie(): SetCookie - { - return $this - ->config->getCookie() - ->withValue(null) - ->withExpires( - $this->config->getClock() - ->now() - ->modify('-30 days'), - ); - } } diff --git a/src/Storageless/Service/SessionStorage.php b/src/Storageless/Service/SessionStorage.php new file mode 100644 index 00000000..b201d809 --- /dev/null +++ b/src/Storageless/Service/SessionStorage.php @@ -0,0 +1,33 @@ +getSameOriginRequest($request); + + $handler ??= fn (ResponseInterface $response): RequestHandlerInterface => new class ($response) implements RequestHandlerInterface { + public function __construct( + private readonly ResponseInterface $response, + ) { + } + + public function handle(Request $request): ResponseInterface + { + return $this->response; + } + }; + + $response ??= $handler->handle($request->withAttribute($this->config->getSessionAttribute(), $session)); + + return $this->appendToken( + $session, + $response, + $this->requestToToken($request, $sameOriginRequest), + $sameOriginRequest, + ); + } + + public function getSession(Request $request): SessionInterface + { + return LazySession::fromToken($this->requestToToken($request)); + } + + public function requestToToken(Request $request, SameOriginRequest|null $sameOriginRequest = null): UnencryptedToken|null + { + /** @var array $cookies */ + $cookies = $request->getCookieParams(); + $cookieName = $this->config->getCookie()->getName(); + + $cookie = $cookies[$cookieName] ?? ''; + + if ($cookie === '') { + return null; + } + + $jwtConfiguration = $this->config->getJwtConfiguration(); + try { + $token = $jwtConfiguration->parser()->parse($cookie); + } catch (InvalidArgumentException) { + return null; + } + + if (! $token instanceof UnencryptedToken) { + return null; + } + + $constraints = [ + new StrictValidAt($this->config->getClock()), + new SignedWith($jwtConfiguration->signer(), $jwtConfiguration->verificationKey()), + $sameOriginRequest ?? $this->getSameOriginRequest($request), + ]; + + if (! $jwtConfiguration->validator()->validate($token, ...$constraints)) { + return null; + } + + return $token; + } + + /** + * @throws BadMethodCallException + * @throws InvalidArgumentException + */ + private function appendToken( + SessionInterface $sessionContainer, + Response $response, + Token|null $token, + SameOriginRequest $sameOriginRequest, + ): Response { + $sessionContainerChanged = $sessionContainer->hasChanged(); + + if ($sessionContainerChanged && $sessionContainer->isEmpty()) { + return FigResponseCookies::set( + $response, + $this + ->config + ->getCookie() + ->withValue(null) + ->withExpires( + $this->config->getClock() + ->now() + ->modify('-30 days'), + ), + ); + } + + if ($sessionContainerChanged || $this->shouldTokenBeRefreshed($token)) { + return FigResponseCookies::set($response, $this->getTokenCookie($sessionContainer, $sameOriginRequest)); + } + + return $response; + } + + private function getSameOriginRequest(Request $request): SameOriginRequest + { + return new SameOriginRequest($this->config->getClientFingerprintConfiguration(), $request); + } + + /** @throws BadMethodCallException */ + private function getTokenCookie(SessionInterface $sessionContainer, SameOriginRequest $sameOriginRequest): SetCookie + { + $now = $this->config->getClock()->now(); + $expiresAt = $now->add(new DateInterval(sprintf('PT%sS', $this->config->getIdleTimeout()))); + + $jwtConfiguration = $this->config->getJwtConfiguration(); + + $builder = $jwtConfiguration->builder(ChainedFormatter::withUnixTimestampDates()) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) + ->expiresAt($expiresAt) + ->withClaim(self::SESSION_CLAIM, $sessionContainer); + + $builder = $sameOriginRequest->configure($builder); + + return $this + ->config->getCookie() + ->withValue( + $builder + ->getToken($jwtConfiguration->signer(), $jwtConfiguration->signingKey()) + ->toString(), + ) + ->withExpires($expiresAt); + } + + private function shouldTokenBeRefreshed(Token|null $token): bool + { + if ($token === null) { + return false; + } + + return $token->hasBeenIssuedBefore( + $this->config->getClock() + ->now() + ->sub(new DateInterval(sprintf('PT%sS', $this->config->getRefreshTime()))), + ); + } +} diff --git a/src/Storageless/Session/LazySession.php b/src/Storageless/Session/LazySession.php index 0fb99fd5..f489eddd 100644 --- a/src/Storageless/Session/LazySession.php +++ b/src/Storageless/Session/LazySession.php @@ -20,6 +20,11 @@ namespace PSR7Sessions\Storageless\Session; +use BadMethodCallException; +use Lcobucci\JWT\UnencryptedToken; +use PSR7Sessions\Storageless\Http\SessionMiddleware; +use stdClass; + final class LazySession implements SessionInterface { /** @internal do not access directly: use {@see LazySession::getRealSession} instead */ @@ -47,6 +52,25 @@ public static function fromContainerBuildingCallback(callable $sessionLoader): s return new self($sessionLoader); } + public static function fromToken(UnencryptedToken|null $token): self + { + return self::fromContainerBuildingCallback( + static function () use ($token): SessionInterface { + if (! $token) { + return DefaultSessionData::newEmptySession(); + } + + try { + return DefaultSessionData::fromDecodedTokenData( + (object) $token->claims()->get(SessionMiddleware::SESSION_CLAIM, new stdClass()), + ); + } catch (BadMethodCallException) { + return DefaultSessionData::newEmptySession(); + } + }, + ); + } + /** * {@inheritDoc} */