From 17e86f37352bcedfad6a7042ecbb86c949b6b2ea Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Thu, 6 Apr 2023 22:17:26 +0200 Subject: [PATCH] add `StoragelessSession` service --- src/Storageless/Service/SessionStorage.php | 34 +++ .../Service/StoragelessSession.php | 264 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 src/Storageless/Service/SessionStorage.php create mode 100644 src/Storageless/Service/StoragelessSession.php diff --git a/src/Storageless/Service/SessionStorage.php b/src/Storageless/Service/SessionStorage.php new file mode 100644 index 00000000..c96d6038 --- /dev/null +++ b/src/Storageless/Service/SessionStorage.php @@ -0,0 +1,34 @@ +cookie = clone $cookie; + } + + public static function fromSymmetricKeyDefaults( + Signer\Key $symmetricKey, + int $idleTimeout = self::DEFAULT_IDLE_TIMEOUT, + Cookie|SetCookie|null $cookie = null, + ClockInterface|null $clock = null, + ): self { + return new self( + Configuration::forSymmetricSigner( + new Signer\Hmac\Sha256(), + $symmetricKey, + ), + $idleTimeout, + $cookie ?? SetCookie::create(self::DEFAULT_COOKIE) + ->withSecure(true) + ->withHttpOnly(true) + ->withSameSite(SameSite::lax()) + ->withPath('/'), + $clock ?? SystemClock::fromUTC(), + ); + } + + public static function fromRsaAsymmetricKeyDefaults( + Signer\Key $privateRsaKey, + Signer\Key $publicRsaKey, + int $idleTimeout = self::DEFAULT_IDLE_TIMEOUT, + Cookie|SetCookie|null $cookie = null, + ClockInterface|null $clock = null, + ): self { + return new self( + Configuration::forAsymmetricSigner( + new Signer\Rsa\Sha256(), + $privateRsaKey, + $publicRsaKey, + ), + $idleTimeout, + $cookie ?? SetCookie::create(self::DEFAULT_COOKIE) + ->withSecure(true) + ->withHttpOnly(true) + ->withSameSite(SameSite::lax()) + ->withPath('/'), + $clock ?? SystemClock::fromUTC(), + ); + } + + public function withSession(MessageInterface $message, SessionInterface $session): MessageInterface + { + return match (true) { + $message instanceof ResponseInterface => $this->withResponseSession($message, $session, $this->clock->now()), + $message instanceof RequestInterface => $this->withRequestSession($message, $session, $this->clock->now()), + default => throw new Exception(sprintf('Message type not supported, only "ServerRequestInterface" or "ResponseInterface".')) + }; + } + + public function get(MessageInterface $message): SessionInterface + { + $cookie = $this->getCookieFromMessage($message); + + return $cookie === null + ? DefaultSessionData::newEmptySession() + : $this->cookieToSession($cookie); + } + + public function getCookieFromMessage(MessageInterface $message): SetCookie|Cookie|null + { + // TODO: Why we cannot use Cookies::fromRequest() ? + // See: https://github.com/dflydev/dflydev-fig-cookies/issues/57 + if ($message instanceof ServerRequestInterface) { + $cookies = $message->getCookieParams(); + + if (! array_key_exists($this->cookie->getName(), $cookies)) { + return null; + } + + $cookieValue = (string) $cookies[$this->cookie->getName()]; + + return Cookie::create($this->cookie->getName(), $cookieValue === '' ? null : $cookieValue); + } + + if ($message instanceof ResponseInterface) { + return SetCookies::fromResponse($message)->get($this->cookie->getName()); + } + + throw new Exception('Unsupported message type'); + } + + public function cookieToToken(SetCookie|Cookie|null $cookie): UnencryptedToken|null + { + if ($cookie === null) { + return null; + } + + $jwt = $cookie->getValue(); + + if ($jwt === null || $jwt === '') { + return null; + } + + try { + $token = $this->configuration->parser()->parse($jwt); + } catch (Throwable) { + return null; + } + + if (! $token instanceof UnencryptedToken) { + return null; + } + + $isValid = $this + ->configuration + ->validator() + ->validate( + $token, + new StrictValidAt($this->clock), + new SignedWith($this->configuration->signer(), $this->configuration->verificationKey()), + ); + + if ($isValid === false) { + return null; + } + + return $token; + } + + private function withRequestSession(RequestInterface $request, SessionInterface $session, DateTimeImmutable $now): RequestInterface + { + if ($session->hasChanged() === false) { + return $request; + } + + if (! $this->cookie instanceof Cookie) { + throw new Exception( + 'The default cookie is not a Cookie type.', + ); + } + + return FigRequestCookies::set( + $request, + $this->appendCookieSession($this->cookie, $session, $now), + ); + } + + private function withResponseSession(ResponseInterface $response, SessionInterface $session, DateTimeImmutable $now): ResponseInterface + { + if (! $this->cookie instanceof SetCookie) { + throw new Exception( + 'The default cookie is not a SetCookie type.', + ); + } + + if ($session->isEmpty()) { + return FigResponseCookies::set( + $response, + $this->cookie->withExpires($now->modify('-30 days')), + ); + } + + return FigResponseCookies::set( + $response, + $this + ->appendCookieSession( + $this->cookie->withExpires($now->add(new DateInterval(sprintf('PT%sS', $this->idleTimeout)))), + $session, + $now, + ), + ); + } + + /** @psalm-return ($cookie is SetCookie ? SetCookie : Cookie) */ + private function appendCookieSession(SetCookie|Cookie $cookie, SessionInterface $session, DateTimeImmutable $now): SetCookie|Cookie + { + $value = $session->isEmpty() + ? null + : $this->configuration->builder(ChainedFormatter::withUnixTimestampDates()) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) + ->expiresAt($now->add(new DateInterval(sprintf('PT%sS', $this->idleTimeout)))) + ->withClaim(self::SESSION_CLAIM, $session) + ->getToken($this->configuration->signer(), $this->configuration->signingKey()) + ->toString(); + + return $cookie->withValue($value); + } + + private function cookieToSession(SetCookie|Cookie $cookie): SessionInterface + { + $token = $this->cookieToToken($cookie); + + if ($token === null) { + return DefaultSessionData::newEmptySession(); + } + + return DefaultSessionData::fromDecodedTokenData( + (object) $token->claims()->get(self::SESSION_CLAIM, new stdClass()), + ); + } +}