Skip to content

Instantly share code, notes, and snippets.

@drupol
Last active April 6, 2023 18:24
Show Gist options
  • Save drupol/b69874bc697fa3ba13226bedcf8c2646 to your computer and use it in GitHub Desktop.
Save drupol/b69874bc697fa3ba13226bedcf8c2646 to your computer and use it in GitHub Desktop.
Request for comments - Into making a generic service - version 5
<?php
/*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* This software consists of voluntary contributions made by many individuals
* and is licensed under the MIT license.
*/
declare(strict_types=1);
namespace PSR7Sessions\Storageless\Http;
use BadMethodCallException;
use DateTimeZone;
use Dflydev\FigCookies\Modifier\SameSite;
use Dflydev\FigCookies\SetCookie;
use InvalidArgumentException;
use Lcobucci\Clock\SystemClock;
use Lcobucci\JWT\Signer;
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\Service\SessionStorage;
use PSR7Sessions\Storageless\Service\StoragelessManager;
use PSR7Sessions\Storageless\Session\SessionInterface;
use function date_default_timezone_get;
final class SessionMiddleware implements MiddlewareInterface
{
public const SESSION_CLAIM = 'session-data';
public const SESSION_ATTRIBUTE = 'session';
public const DEFAULT_COOKIE = '__Secure-slsession';
public const DEFAULT_REFRESH_TIME = 60;
public function __construct(
private readonly SessionStorage $sessionStorage
) {}
/**
* This constructor simplifies instantiation when using HTTPS (REQUIRED!) and symmetric key encryption
*/
public static function fromSymmetricKeyDefaults(Signer\Key $symmetricKey, int $idleTimeout): self
{
return new self(
StoragelessManager::fromSymmetricKeyDefaults(
$symmetricKey,
$idleTimeout,
self::DEFAULT_REFRESH_TIME,
self::buildDefaultCookie(),
new SystemClock(new DateTimeZone(date_default_timezone_get()))
)
);
}
/**
* This constructor simplifies instantiation when using HTTPS (REQUIRED!) and asymmetric key encryption
* based on RSA keys
*/
public static function fromRsaAsymmetricKeyDefaults(
Signer\Key $privateRsaKey,
Signer\Key $publicRsaKey,
int $idleTimeout,
): self {
return new self(
StoragelessManager::fromRsaAsymmetricKeyDefaults(
$privateRsaKey,
$publicRsaKey,
$idleTimeout,
self::DEFAULT_REFRESH_TIME,
self::buildDefaultCookie(),
new SystemClock(new DateTimeZone(date_default_timezone_get()))
)
);
}
public static function buildDefaultCookie(): SetCookie
{
return SetCookie::create(self::DEFAULT_COOKIE)
->withSecure(true)
->withHttpOnly(true)
->withSameSite(SameSite::lax())
->withPath('/');
}
/**
* {@inheritdoc}
*
* @throws BadMethodCallException
* @throws InvalidArgumentException
*/
public function process(Request $request, RequestHandlerInterface $handler): Response
{
return $this
->sessionStorage
->handle(
$request,
$handler->handle($request),
static function (SessionInterface $session): void {}
);
}
}
<?php
declare(strict_types=1);
namespace PSR7Sessions\Storageless\Service;
use Closure;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use PSR7Sessions\Storageless\Session\SessionInterface;
interface SessionStorage
{
public function get(RequestInterface $request): SessionInterface;
/**
* @param Closure(SessionInterface): void $callback
*/
public function handle(RequestInterface $request, ResponseInterface $response, Closure $callback): ResponseInterface;
}
<?php
declare(strict_types=1);
namespace PSR7Sessions\Storageless\Service;
use Closure;
use DateInterval;
use Dflydev\FigCookies\Cookie;
use Dflydev\FigCookies\Cookies;
use Dflydev\FigCookies\FigRequestCookies;
use Dflydev\FigCookies\FigResponseCookies;
use Dflydev\FigCookies\Modifier\SameSite;
use Dflydev\FigCookies\SetCookie;
use Dflydev\FigCookies\SetCookies;
use Lcobucci\Clock\SystemClock;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
use Psr\Clock\ClockInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use PSR7Sessions\Storageless\Session\DefaultSessionData;
use PSR7Sessions\Storageless\Session\SessionInterface;
final class StoragelessManager implements SessionStorage
{
private const DEFAULT_COOKIE = '__Secure-slsession';
private const SESSION_CLAIM = 'data';
public const DEFAULT_REFRESH_TIME = 60;
public const DEFAULT_IDLE_TIMEOUT = 300;
public function __construct(
private readonly Configuration $configuration,
private readonly int $idleTimeout,
private readonly int $refreshTime,
private readonly SetCookie $cookie,
private readonly ClockInterface $clock,
) {}
public static function fromSymmetricKeyDefaults(
Key $symmetricKey,
int $idleTimeout = self::DEFAULT_IDLE_TIMEOUT,
int $refreshTime = self::DEFAULT_REFRESH_TIME,
?SetCookie $cookie = null,
?ClockInterface $clock = null
): self {
return new self(
Configuration::forSymmetricSigner(
new Sha256(),
$symmetricKey,
),
$idleTimeout,
$refreshTime,
$cookie ?? SetCookie::create(self::DEFAULT_COOKIE)
->withSecure(true)
->withHttpOnly(true)
->withSameSite(SameSite::lax())
->withPath('/'),
$clock ?? SystemClock::fromUTC(),
);
}
public static function fromRsaAsymmetricKeyDefaults(
Key $privateRsaKey,
Key $publicRsaKey,
int $idleTimeout = self::DEFAULT_IDLE_TIMEOUT,
int $refreshTime = self::DEFAULT_REFRESH_TIME,
?SetCookie $cookie = null,
?ClockInterface $clock = null
): self {
return new self(
Configuration::forAsymmetricSigner(
new Sha256(),
$privateRsaKey,
$publicRsaKey,
),
$idleTimeout,
$refreshTime,
$cookie ?? SetCookie::create(self::DEFAULT_COOKIE)
->withSecure(true)
->withHttpOnly(true)
->withSameSite(SameSite::lax())
->withPath('/'),
$clock ?? SystemClock::fromUTC(),
);
}
public function handle(RequestInterface $request, ResponseInterface $response, Closure $callback): ResponseInterface {
$session = $this->get($request);
// This callback is supposed to alter the $session, thus, it is
// not supposed to return anything. On top of that, SessionInterface is
// stateful, so, it's not needed at all.
$callback($session);
return $this->withResponse($request, $response, $session);
}
public function get(RequestInterface $request): SessionInterface {
$cookie = $this->getCookieFromRequest($request);
if (null === $cookie) {
return DefaultSessionData::newEmptySession();
}
try {
$session = $this->cookieToSession($cookie);
} catch (\Throwable $exception) {
$session = DefaultSessionData::newEmptySession();
}
return $session;
}
private function getFromResponse(ResponseInterface $response): SessionInterface {
$cookie = SetCookies::fromResponse($response)->get($this->cookie->getName());
if (null === $cookie) {
return DefaultSessionData::newEmptySession();
}
try {
$session = $this->cookieToSession($cookie);
} catch (\Throwable $exception) {
$session = DefaultSessionData::newEmptySession();
}
return $session;
}
private function withResponse(RequestInterface $request, ResponseInterface $response, SessionInterface $session): ResponseInterface {
$sessionContainerChanged = $session->hasChanged();
if ($sessionContainerChanged && $session->isEmpty()) {
return FigResponseCookies::set($response, $this->getExpirationCookie());
}
$token = $this->cookieToToken($this->getCookieFromRequest($request));
if ($sessionContainerChanged || $this->shouldTokenBeRefreshed($token)) {
return FigResponseCookies::set($response, $this->sessionToSetCookie($session));
}
return $response;
}
private function getCookieFromRequest(RequestInterface $request): ?Cookie {
return Cookies::fromRequest($request)->get($this->cookie->getName());
}
private function shouldTokenBeRefreshed(?Token $token): bool
{
if ($token === null) {
return false;
}
return $token->hasBeenIssuedBefore(
$this->clock
->now()
->sub(new DateInterval(sprintf('PT%sS', $this->refreshTime))),
);
}
private function getExpirationCookie(): SetCookie
{
return $this
->cookie
->withValue(null)
->withExpires(
$this->clock
->now()
->modify('-30 days'),
);
}
private function cookieToToken(SetCookie|Cookie|null $cookie): ?Token {
if (null === $cookie) {
return null;
}
$token = $this->configuration->parser()->parse($cookie->getValue());
if (! $token instanceof UnencryptedToken) {
return null;
}
return $token;
}
private function cookieToSession(SetCookie|Cookie $cookie): SessionInterface {
$token = $this->cookieToToken($cookie);
if (null === $token) {
return DefaultSessionData::newEmptySession();
}
$constraints = [
new StrictValidAt($this->clock),
new SignedWith($this->configuration->signer(), $this->configuration->verificationKey()),
];
if (false === $this->configuration->validator()->validate($token, ...$constraints)) {
return DefaultSessionData::newEmptySession();
}
try {
$session = DefaultSessionData::fromDecodedTokenData(
(object) $token->claims()->get(self::SESSION_CLAIM, new \stdClass()),
);
} catch (\Throwable) {
$session = DefaultSessionData::newEmptySession();
}
return $session;
}
private function sessionToSetCookie(SessionInterface $session): SetCookie
{
$now = $this->clock->now();
$expiresAt = $now->add(new \DateInterval(sprintf('PT%sS', $this->idleTimeout)));
return $this->appendCookieValue(
$this->cookie->withExpires($expiresAt),
$session
);
}
private function appendCookieValue(SetCookie|Cookie $cookie, SessionInterface $session): SetCookie|Cookie {
$now = $this->clock->now();
$expiresAt = $now->add(new DateInterval(sprintf('PT%sS', $this->idleTimeout)));
return $cookie
->withValue(
$this->configuration->builder(ChainedFormatter::withUnixTimestampDates())
->issuedAt($now)
->canOnlyBeUsedAfter($now)
->expiresAt($expiresAt)
->withClaim(self::SESSION_CLAIM, $session)
->getToken($this->configuration->signer(), $this->configuration->signingKey())
->toString(),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment