Last active
April 6, 2023 18:24
-
-
Save drupol/b69874bc697fa3ba13226bedcf8c2646 to your computer and use it in GitHub Desktop.
Request for comments - Into making a generic service - version 5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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 {} | |
); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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