chore: add php-cs-fixer with PSR-12 config and CI check
- Add friendsofphp/php-cs-fixer to require-dev - Create .php-cs-fixer.dist.php configured for @PSR12 ruleset - Add php-cs-fixer dry-run step to CI pipeline - Auto-fix existing PSR-12 violations - Document code style tooling in readme.md
This commit is contained in:
+5
-2
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
@@ -8,8 +9,10 @@ use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
|
||||
|
||||
#[AsAlias(ClockInterface::class)]
|
||||
final readonly class Clock implements ClockInterface {
|
||||
public function now(): DateTimeImmutable {
|
||||
final readonly class Clock implements ClockInterface
|
||||
{
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
@@ -13,7 +14,8 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/** simple console command to generate backup codes
|
||||
* usage: php bin/console app:generate-backup-codes [count] */
|
||||
final class GenerateBackupCodesCommand extends Command {
|
||||
final class GenerateBackupCodesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BackupCodeInterface $manager,
|
||||
private readonly PersistCache $persistCache,
|
||||
@@ -21,14 +23,16 @@ final class GenerateBackupCodesCommand extends Command {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('app:generate-backup-codes');
|
||||
$this->setDescription('Generate single‑use backup codes')
|
||||
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
|
||||
$this->persistCache->boot();
|
||||
$count = (int) $input->getArgument('count');
|
||||
|
||||
+19
-9
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
@@ -7,7 +8,8 @@ use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class ConfigBag {
|
||||
final readonly class ConfigBag
|
||||
{
|
||||
private ClockInterface $clock;
|
||||
private int $cookieTtl;
|
||||
private string $totpUri;
|
||||
@@ -39,35 +41,43 @@ final readonly class ConfigBag {
|
||||
$this->tooManyTitle = $tooManyTitle;
|
||||
}
|
||||
|
||||
public function clock(): ClockInterface {
|
||||
public function clock(): ClockInterface
|
||||
{
|
||||
return $this->clock;
|
||||
}
|
||||
|
||||
public function cookieTtl(): int {
|
||||
public function cookieTtl(): int
|
||||
{
|
||||
return $this->cookieTtl;
|
||||
}
|
||||
|
||||
public function totpUri(): string {
|
||||
public function totpUri(): string
|
||||
{
|
||||
return $this->totpUri;
|
||||
}
|
||||
|
||||
public function ipTtl(): ?int {
|
||||
public function ipTtl(): ?int
|
||||
{
|
||||
return $this->ipTtl;
|
||||
}
|
||||
|
||||
public function teapot(): bool {
|
||||
public function teapot(): bool
|
||||
{
|
||||
return $this->teapot;
|
||||
}
|
||||
|
||||
public function errorMessage(): string {
|
||||
public function errorMessage(): string
|
||||
{
|
||||
return $this->errorMessage;
|
||||
}
|
||||
|
||||
public function teapotTitle(): string {
|
||||
public function teapotTitle(): string
|
||||
{
|
||||
return $this->teapotTitle;
|
||||
}
|
||||
|
||||
public function tooManyTitle(): string {
|
||||
public function tooManyTitle(): string
|
||||
{
|
||||
return $this->tooManyTitle;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-6
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
@@ -7,14 +8,16 @@ use App\Enum\Scope;
|
||||
use Symfony\Component\HttpFoundation\InputBag;
|
||||
|
||||
/** when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||
final class Payload {
|
||||
final class Payload
|
||||
{
|
||||
public string $id; /* session name, identifying who is logging in */
|
||||
public string $token; /* TOTP, typically six digits */
|
||||
public string $nonce; /* random unique string, to block duplicate submissions */
|
||||
public bool $json; /* should we return json (for the login page) */
|
||||
public Scope $scope; /* type of access being requested */
|
||||
|
||||
public static function decode(string $base64url): ?Payload {
|
||||
public static function decode(string $base64url): ?Payload
|
||||
{
|
||||
/* convert the base64url into json string */
|
||||
$base64 = strtr($base64url, '-_', '+/');
|
||||
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
||||
@@ -29,7 +32,8 @@ final class Payload {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function load(InputBag $input): ?Payload {
|
||||
public static function load(InputBag $input): ?Payload
|
||||
{
|
||||
/* convert form data into real data */
|
||||
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
|
||||
return Payload::create((object)[
|
||||
@@ -42,7 +46,8 @@ final class Payload {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function create(object $data): ?Payload {
|
||||
public static function create(object $data): ?Payload
|
||||
{
|
||||
/* if missing required fields id, nonce, or token */
|
||||
if (strlen(trim($data->id ?? '')) < 1 ||
|
||||
strlen(trim($data->nonce ?? '')) < 1 ||
|
||||
@@ -63,11 +68,13 @@ final class Payload {
|
||||
return Payload::constrict($payload);
|
||||
}
|
||||
|
||||
public function toString(): string {
|
||||
public function toString(): string
|
||||
{
|
||||
return json_encode($this);
|
||||
}
|
||||
|
||||
private static function constrict(Payload $payload): Payload {
|
||||
private static function constrict(Payload $payload): Payload
|
||||
{
|
||||
/* When scope is None, json will be considered false. */
|
||||
if ($payload->scope === Scope::None) {
|
||||
$payload->json = false;
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/** scope defines the context of how a session is persisted */
|
||||
enum Scope: string {
|
||||
enum Scope: string
|
||||
{
|
||||
case Cookie = 'cookie';
|
||||
case Ip = 'ip';
|
||||
case None = 'none';
|
||||
|
||||
+7
-3
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
@@ -9,13 +10,15 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel {
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
|
||||
private PersistCache $persistCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function boot(): void {
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
$this->persistCache = $this->container->get(PersistCache::class);
|
||||
@@ -23,7 +26,8 @@ class Kernel extends BaseKernel {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function terminate(Request $request, Response $response): void {
|
||||
public function terminate(Request $request, Response $response): void
|
||||
{
|
||||
$this->persistCache->persist();
|
||||
|
||||
parent::terminate($request, $response);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
@@ -13,7 +14,8 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AcceptListener {
|
||||
final readonly class AcceptListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
@@ -21,13 +23,15 @@ final readonly class AcceptListener {
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
#[AsEventListener(priority: 99)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
/* check if they sent the correct preauth cookie */
|
||||
$cookieName = $this->domainManager->authBase() ?$this->authCookieName() : $this->cookieName();
|
||||
$cookieName = $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName();
|
||||
if ($event->getRequest()->cookies->has($cookieName)) {
|
||||
$cookie = $event->getRequest()->cookies->get($cookieName);
|
||||
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
@@ -12,18 +13,21 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AllowListener {
|
||||
final readonly class AllowListener
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private ConfigBag $config,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
#[AsEventListener(priority: 88)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if ($this->config->ipTtl() > 0) {
|
||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||
if ($this->sessionCache->hasItem($ipKey)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
@@ -18,7 +19,8 @@ use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class InterceptListener {
|
||||
final readonly class InterceptListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
@@ -27,11 +29,13 @@ final readonly class InterceptListener {
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
private Environment $twig,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
#[AsEventListener(priority: 55)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
/* by this point, we know that the request we have is:
|
||||
* not already authorized, nor already rate-limited,
|
||||
* nor submitting login credentials; so redirect or present the login page now */
|
||||
@@ -40,7 +44,9 @@ final readonly class InterceptListener {
|
||||
) {
|
||||
/* host matches base-domain of auth, but not on auth subdomain, redirect */
|
||||
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
|
||||
$event->setResponse(new Response('', Response::HTTP_SEE_OTHER,
|
||||
$event->setResponse(new Response(
|
||||
'',
|
||||
Response::HTTP_SEE_OTHER,
|
||||
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
|
||||
));
|
||||
} else {
|
||||
@@ -52,13 +58,16 @@ final readonly class InterceptListener {
|
||||
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
||||
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
|
||||
);
|
||||
$event->setResponse($this->pruneInvalidCookie(new Response($content,
|
||||
Response::HTTP_UNAUTHORIZED, ['Content-Type' => 'text/html']
|
||||
$event->setResponse($this->pruneInvalidCookie(new Response(
|
||||
$content,
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['Content-Type' => 'text/html']
|
||||
), $hasCookie, $event->getRequest()->getHost()));
|
||||
}
|
||||
}
|
||||
|
||||
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response {
|
||||
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response
|
||||
{
|
||||
if ($hasCookie) {
|
||||
/* input here must match LoginListener::setCookie() */
|
||||
$response->headers->clearCookie(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
@@ -23,7 +24,8 @@ use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class LoginListener {
|
||||
final readonly class LoginListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
@@ -32,18 +34,19 @@ final readonly class LoginListener {
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
private DomainInterface $domainManager,
|
||||
private LoginInterface $loginManager,
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
private LoginInterface $loginManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
||||
#[AsEventListener(priority: 66)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$payload = null;
|
||||
$response = null;
|
||||
|
||||
@@ -51,7 +54,7 @@ final readonly class LoginListener {
|
||||
/* if request contains our "X-Preauth" header */
|
||||
$data = $event->getRequest()->headers->get($this->headerName());
|
||||
$payload = Payload::decode($data);
|
||||
} else if ($event->getRequest()->isMethod(Request::METHOD_POST) &&
|
||||
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
|
||||
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
||||
) {
|
||||
/* if request is a POST to the auth-subdomain */
|
||||
@@ -76,18 +79,23 @@ final readonly class LoginListener {
|
||||
$limitReached = $this->logFailure($event->getRequest());
|
||||
|
||||
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
|
||||
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
|
||||
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
|
||||
$event->setResponse($this->makeFailedResponse(
|
||||
$limitReached,
|
||||
$payload->json ?? true,
|
||||
$event->getRequest()->getHost(),
|
||||
$this->makeCacheKey($payload ? $payload->id : '')
|
||||
));
|
||||
}
|
||||
|
||||
private function logFailure(Request $request): bool {
|
||||
private function logFailure(Request $request): bool
|
||||
{
|
||||
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||
return ($limiter->consume(1)->getRemainingTokens() < 1);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response {
|
||||
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
|
||||
{
|
||||
if ($limited) {
|
||||
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
|
||||
: Response::HTTP_TOO_MANY_REQUESTS;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
@@ -16,15 +17,16 @@ use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class RejectListener {
|
||||
final readonly class RejectListener
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
@@ -32,13 +34,16 @@ final readonly class RejectListener {
|
||||
|
||||
/** @throws SyntaxError|RuntimeError|LoaderError */
|
||||
#[AsEventListener(priority: 77)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
/* check if they have made too many failed login attempts */
|
||||
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
|
||||
if ($limiter->consume(0)->getRemainingTokens() < 1) {
|
||||
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
|
||||
$html = $this->twig->render('error.html.twig');
|
||||
$event->setResponse(new Response($html, ($this->config->teapot()
|
||||
$event->setResponse(new Response(
|
||||
$html,
|
||||
($this->config->teapot()
|
||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
||||
['Content-Type' => 'text/html']
|
||||
));
|
||||
|
||||
+41
-21
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
@@ -10,7 +11,8 @@ use Psr\Cache\InvalidArgumentException;
|
||||
|
||||
/* we must *NOT* store the key-list item or values within this object
|
||||
* because it can change from outside this object instance */
|
||||
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
{
|
||||
private const string KEY_LIST = '__key_list';
|
||||
private const string CHANGE_LIST = '__chg_list';
|
||||
public const int UPDATED = 1;
|
||||
@@ -19,11 +21,12 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
private CacheItemPoolInterface $cache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(CacheItemPoolInterface $cache) {
|
||||
public function __construct(CacheItemPoolInterface $cache)
|
||||
{
|
||||
$this->cache = $cache;
|
||||
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
||||
foreach ($items as $item) {
|
||||
if ( ! $item->isHit()) {
|
||||
if (! $item->isHit()) {
|
||||
$this->initialize();
|
||||
break;
|
||||
}
|
||||
@@ -31,7 +34,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function initialize(): void {
|
||||
private function initialize(): void
|
||||
{
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$keyList->set([]);
|
||||
@@ -42,42 +46,49 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function getKeys(): array {
|
||||
public function getKeys(): array
|
||||
{
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
return array_keys($keyList->get() ?? []);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function getChanges(): array {
|
||||
public function getChanges(): array
|
||||
{
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
return $changeList->get() ?? [];
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function markClean(): void {
|
||||
public function markClean(): void
|
||||
{
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$changeList->set([]);
|
||||
$this->cache->save($changeList);
|
||||
}
|
||||
|
||||
public function getItem(string $key): CacheItemInterface {
|
||||
public function getItem(string $key): CacheItemInterface
|
||||
{
|
||||
return $this->cache->getItem($key);
|
||||
}
|
||||
|
||||
/** @return CacheItemInterface[]
|
||||
* @throws InvalidArgumentException */
|
||||
public function getItems(array $keys = []): iterable {
|
||||
public function getItems(array $keys = []): iterable
|
||||
{
|
||||
return $this->cache->getItems($keys);
|
||||
}
|
||||
|
||||
public function hasItem(string $key): bool {
|
||||
public function hasItem(string $key): bool
|
||||
{
|
||||
return $this->cache->hasItem($key);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function clear(): bool {
|
||||
public function clear(): bool
|
||||
{
|
||||
/* only bother clearing the pool if it is not empty */
|
||||
if ( ! empty($this->getKeys())) {
|
||||
if (! empty($this->getKeys())) {
|
||||
$response = $this->cache->clear();
|
||||
|
||||
$this->initialize();
|
||||
@@ -86,7 +97,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function deleteItem(string $key): bool {
|
||||
public function deleteItem(string $key): bool
|
||||
{
|
||||
$this->isValid($key);
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$keyValues = $keyList->get();
|
||||
@@ -101,7 +113,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
return $this->cache->deleteItem($key);
|
||||
}
|
||||
|
||||
public function deleteItems(array $keys): bool {
|
||||
public function deleteItems(array $keys): bool
|
||||
{
|
||||
$this->allValid($keys);
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$keyValues = $keyList->get();
|
||||
@@ -119,23 +132,27 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function save(CacheItemInterface $item): bool {
|
||||
public function save(CacheItemInterface $item): bool
|
||||
{
|
||||
$this->update($item);
|
||||
return $this->cache->save($item);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function saveDeferred(CacheItemInterface $item): bool {
|
||||
public function saveDeferred(CacheItemInterface $item): bool
|
||||
{
|
||||
$this->update($item);
|
||||
return $this->cache->saveDeferred($item);
|
||||
}
|
||||
|
||||
public function commit(): bool {
|
||||
public function commit(): bool
|
||||
{
|
||||
return $this->cache->commit();
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|OutOfBoundsException */
|
||||
private function update(CacheItemInterface $item): void {
|
||||
private function update(CacheItemInterface $item): void
|
||||
{
|
||||
$this->isValid($item->getKey());
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$keyValues = $keyList->get();
|
||||
@@ -147,7 +164,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
/** @throws OutOfBoundsException */
|
||||
private function isValid(string $key): void {
|
||||
private function isValid(string $key): void
|
||||
{
|
||||
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not modify the private key or change lists'
|
||||
@@ -156,7 +174,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
/** @throws OutOfBoundsException */
|
||||
private function allValid(array $keys): void {
|
||||
private function allValid(array $keys): void
|
||||
{
|
||||
if (in_array(self::KEY_LIST, $keys, true) ||
|
||||
in_array(self::CHANGE_LIST, $keys, true)
|
||||
) {
|
||||
@@ -167,7 +186,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void {
|
||||
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void
|
||||
{
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$changeValues = $changeList->get();
|
||||
$changeValues[$key] = $code;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
@@ -9,7 +10,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
|
||||
/* need autoconfigure so we get it from the service container in Kernel->boot() */
|
||||
#[Autoconfigure(public: true)]
|
||||
final readonly class PersistCache {
|
||||
final readonly class PersistCache
|
||||
{
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
private MonitorCacheKeys $sessionStorage;
|
||||
|
||||
@@ -23,7 +25,8 @@ final readonly class PersistCache {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function boot(): void {
|
||||
public function boot(): void
|
||||
{
|
||||
/* the caches are considered warm as soon as they are not empty */
|
||||
if (empty($this->sessionCache->getKeys())) {
|
||||
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
|
||||
@@ -36,7 +39,8 @@ final readonly class PersistCache {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function persist(): void {
|
||||
public function persist(): void
|
||||
{
|
||||
/* we only need to persist the changes made to the cache (if any) */
|
||||
$changes = $this->sessionCache->getChanges();
|
||||
if ($changes) {
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
|
||||
use Exception;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
|
||||
/** backup-codes are case‑insensitive alphanumeric strings
|
||||
* they are single-use and marked as used after successful authentication */
|
||||
interface BackupCodeInterface {
|
||||
interface BackupCodeInterface
|
||||
{
|
||||
/** generate a set of backup-codes and return them
|
||||
* @param int $count Number of codes to generate
|
||||
* @return string[] Generated backup codes
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
@@ -14,19 +15,21 @@ use App\Trait\GetTotpTrait;
|
||||
|
||||
/** backup-codes are case‑insensitive alphanumeric strings
|
||||
* they are single-use and marked as used after successful authentication */
|
||||
final readonly class BackupCodeManager implements BackupCodeInterface {
|
||||
final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
{
|
||||
use GetTotpTrait;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
private const int DEFAULT_COUNT = 10;
|
||||
/* php base_convert() will break if given too long of an input */
|
||||
const int MAX_LENGTH = 64;
|
||||
public const int MAX_LENGTH = 64;
|
||||
|
||||
private CacheItemPoolInterface $sessionCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(CacheItemPoolInterface $sessionCache) {
|
||||
public function __construct(CacheItemPoolInterface $sessionCache)
|
||||
{
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
@@ -34,7 +37,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
|
||||
* @param int $count Number of codes to generate
|
||||
* @return string[] Generated backup codes
|
||||
* @throws InvalidArgumentException|Exception */
|
||||
public function generate(int $count = self::DEFAULT_COUNT): array {
|
||||
public function generate(int $count = self::DEFAULT_COUNT): array
|
||||
{
|
||||
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
@@ -49,7 +53,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function expire(): void {
|
||||
public function expire(): void
|
||||
{
|
||||
$itemsToRemove = [];
|
||||
foreach ($this->sessionCache->getKeys() as $key) {
|
||||
if (str_starts_with($key, 'backup_')) {
|
||||
@@ -65,7 +70,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
|
||||
* @param string $code Code supplied by the client
|
||||
* @return bool true if the code is valid and unused
|
||||
* @throws InvalidArgumentException */
|
||||
public function verifyAndConsume(string $code): bool {
|
||||
public function verifyAndConsume(string $code): bool
|
||||
{
|
||||
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
|
||||
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
|
||||
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
|
||||
@@ -77,7 +83,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d', '2999-12-31'
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
));
|
||||
$this->sessionCache->save($backupItem);
|
||||
|
||||
@@ -87,7 +94,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function saveCodes(array $codes): void {
|
||||
private function saveCodes(array $codes): void
|
||||
{
|
||||
foreach ($codes as $code) {
|
||||
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
|
||||
/* mark backup code as ready */
|
||||
@@ -95,7 +103,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d', '2999-12-31'
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
));
|
||||
$this->sessionCache->saveDeferred($backupItem);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
interface DomainInterface {
|
||||
interface DomainInterface
|
||||
{
|
||||
/** IE: "auth.example.com" or null if not using a separate subdomain
|
||||
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||
public function getAuthSubdomain(): ?string;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class DomainManager implements DomainInterface {
|
||||
final readonly class DomainManager implements DomainInterface
|
||||
{
|
||||
/* top-level-domains which are known to have multiple parts */
|
||||
private const array TLD = [
|
||||
'ai' => ['com','net','off','org'],
|
||||
@@ -38,7 +40,8 @@ final readonly class DomainManager implements DomainInterface {
|
||||
|
||||
/** IE: "auth.example.com" or null if not using a separate subdomain
|
||||
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||
public function getAuthSubdomain(): ?string {
|
||||
public function getAuthSubdomain(): ?string
|
||||
{
|
||||
if ($this->authBase()) {
|
||||
return $this->authSubdomain;
|
||||
}
|
||||
@@ -48,7 +51,8 @@ final readonly class DomainManager implements DomainInterface {
|
||||
/** check if given url is an acceptable url for redirection
|
||||
* @param string $url Where we are thinking of sending the user
|
||||
* @return bool Returns true if it is acceptable to send the user there */
|
||||
public function validReturn(string $url): bool {
|
||||
public function validReturn(string $url): bool
|
||||
{
|
||||
/* ensure url is valid and, when using an auth subdomain,
|
||||
* that the url host matches the base domain */
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
@@ -70,7 +74,8 @@ final readonly class DomainManager implements DomainInterface {
|
||||
/** check if host-base matches auth-base
|
||||
* @param string $host
|
||||
* @return bool returns true if and only if host matches base domain of auth */
|
||||
public function matchesAuth(string $host): bool {
|
||||
public function matchesAuth(string $host): bool
|
||||
{
|
||||
$hostBase = $this->baseDomain($host);
|
||||
$authBase = $this->baseDomain($this->authSubdomain);
|
||||
return $this->subdomainRedirect && $this->authSubdomain &&
|
||||
@@ -79,7 +84,8 @@ final readonly class DomainManager implements DomainInterface {
|
||||
|
||||
/** IE: "example.com" if central auth is something like "auth.example.com"
|
||||
* @return string|null returns base domain if we are doing central auth */
|
||||
public function authBase(): ?string {
|
||||
public function authBase(): ?string
|
||||
{
|
||||
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
|
||||
return $this->baseDomain($this->authSubdomain);
|
||||
}
|
||||
@@ -91,7 +97,8 @@ final readonly class DomainManager implements DomainInterface {
|
||||
* things like "localhost" and "8.8.8.8" will return null
|
||||
* @param string $host ip, localhost, or domain with zero or more subdomains
|
||||
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
|
||||
private function baseDomain(string $host): ?string {
|
||||
private function baseDomain(string $host): ?string
|
||||
{
|
||||
/* if host is an ip address (or localhost), leave it as is */
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
|
||||
return null;
|
||||
@@ -106,12 +113,13 @@ final readonly class DomainManager implements DomainInterface {
|
||||
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
|
||||
* @param string[] $parts pieces of a domain split by "." dot
|
||||
* @return int typically 2 but sometimes 3 */
|
||||
private function baseLength(array $parts): int {
|
||||
private function baseLength(array $parts): int
|
||||
{
|
||||
$length = count($parts);
|
||||
$baseLength = min(2, $length);
|
||||
/* check if host should retain 3 parts, due to TLD */
|
||||
if (count($parts) > 2 && isset(self::TLD[$parts[$length-1]]) &&
|
||||
in_array($parts[$length-2], self::TLD[$parts[$length-1]], true)
|
||||
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
|
||||
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
|
||||
) {
|
||||
$baseLength = min(3, $length);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
interface LoginInterface {
|
||||
interface LoginInterface
|
||||
{
|
||||
/** @throws InvalidArgumentException */
|
||||
public function checkToken(Payload $payload, Request $request): ?Response;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
@@ -18,7 +19,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
final readonly class LoginManager implements LoginInterface {
|
||||
final readonly class LoginManager implements LoginInterface
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use GetTotpTrait;
|
||||
use MakeNonceTrait;
|
||||
@@ -28,7 +30,7 @@ final readonly class LoginManager implements LoginInterface {
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
private BackupCodeInterface $backupCodeManager,
|
||||
private DomainInterface $domainManager,
|
||||
) {
|
||||
@@ -36,7 +38,8 @@ final readonly class LoginManager implements LoginInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function checkToken(Payload $payload, Request $request): ?Response {
|
||||
public function checkToken(Payload $payload, Request $request): ?Response
|
||||
{
|
||||
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
|
||||
/* requested to grant ip access, but that is not enabled */
|
||||
@@ -69,7 +72,7 @@ final readonly class LoginManager implements LoginInterface {
|
||||
/* grant access based on the requested scope */
|
||||
if ($payload->scope === Scope::Cookie) {
|
||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||
} else if ($payload->scope === Scope::Ip) {
|
||||
} elseif ($payload->scope === Scope::Ip) {
|
||||
$this->setIp($cleanId, $request->getClientIp());
|
||||
}
|
||||
|
||||
@@ -104,7 +107,8 @@ final readonly class LoginManager implements LoginInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setCookie(string $id, string $host): Cookie {
|
||||
private function setCookie(string $id, string $host): Cookie
|
||||
{
|
||||
/* successful auth with token, store session and set the cookie */
|
||||
$ulid = new Ulid();
|
||||
$sessionCookie = $this->sessionCache->getItem(
|
||||
@@ -136,7 +140,8 @@ final readonly class LoginManager implements LoginInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setIp(string $id, string $ip): void {
|
||||
private function setIp(string $id, string $ip): void
|
||||
{
|
||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
trait CookieNameTrait {
|
||||
trait CookieNameTrait
|
||||
{
|
||||
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
||||
private const string HEADER_NAME = 'X-Preauth';
|
||||
|
||||
final protected function cookieName(): string {
|
||||
final protected function cookieName(): string
|
||||
{
|
||||
return static::COOKIE_NAME;
|
||||
}
|
||||
|
||||
final protected function authCookieName(): string {
|
||||
final protected function authCookieName(): string
|
||||
{
|
||||
return static::AUTH_COOKIE_NAME;
|
||||
}
|
||||
|
||||
final protected function headerName(): string {
|
||||
final protected function headerName(): string
|
||||
{
|
||||
return static::HEADER_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
@@ -9,17 +10,21 @@ use OTPHP\TOTPInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait GetTotpTrait {
|
||||
trait GetTotpTrait
|
||||
{
|
||||
protected readonly ConfigBag $config;
|
||||
|
||||
#[Required]
|
||||
public function setConfig(ConfigBag $config): void {
|
||||
public function setConfig(ConfigBag $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
protected function getTotp(): TOTPInterface {
|
||||
protected function getTotp(): TOTPInterface
|
||||
{
|
||||
$otp = Factory::loadFromProvisioningUri(
|
||||
$this->config->totpUri(), $this->config->clock()
|
||||
$this->config->totpUri(),
|
||||
$this->config->clock()
|
||||
);
|
||||
if ($otp instanceof TOTPInterface) {
|
||||
return $otp;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
@@ -6,11 +7,13 @@ namespace App\Trait;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait HasLoggerTrait {
|
||||
trait HasLoggerTrait
|
||||
{
|
||||
protected readonly LoggerInterface $logger;
|
||||
|
||||
#[Required]
|
||||
public function setLogger(LoggerInterface $logger): void {
|
||||
public function setLogger(LoggerInterface $logger): void
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
@@ -10,7 +11,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait MakeNonceTrait {
|
||||
trait MakeNonceTrait
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
@@ -21,12 +23,14 @@ trait MakeNonceTrait {
|
||||
protected readonly CacheItemPoolInterface $nonceCache;
|
||||
|
||||
#[Required]
|
||||
public function setNonceCache(CacheItemPoolInterface $nonceCache): void {
|
||||
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
|
||||
{
|
||||
$this->nonceCache = $nonceCache;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|Exception */
|
||||
protected function makeNonce(int $retries = 3): string {
|
||||
protected function makeNonce(int $retries = 3): string
|
||||
{
|
||||
/* convert raw binary into base64url */
|
||||
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
||||
static::NONCE_LENGTH
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
trait StringTrait {
|
||||
trait StringTrait
|
||||
{
|
||||
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
|
||||
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
|
||||
|
||||
public function makeCacheKey(string $name): string {
|
||||
public function makeCacheKey(string $name): string
|
||||
{
|
||||
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-8
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
@@ -11,14 +12,17 @@ use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
final readonly class Utilities {
|
||||
final readonly class Utilities
|
||||
{
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private CacheItemPoolInterface $appPool,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function loadTotp(): string {
|
||||
public function loadTotp(): string
|
||||
{
|
||||
/* user forgot to set their TOTP_URI in the environment */
|
||||
if ($this->appPool->hasItem('totp')) {
|
||||
$totp = $this->appPool->getItem('totp')->get();
|
||||
@@ -31,7 +35,8 @@ final readonly class Utilities {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function makeTotp(): string {
|
||||
private function makeTotp(): string
|
||||
{
|
||||
/* we have not stored a totp into the app cache yet */
|
||||
$totpObj = TOTP::generate($this->clock);
|
||||
$totpObj->setLabel('Preauth-TOTP');
|
||||
@@ -41,21 +46,25 @@ final readonly class Utilities {
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d', '2999-12-31'
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
));
|
||||
$this->appPool->save($totpItem);
|
||||
return $totp;
|
||||
}
|
||||
|
||||
private function showTotp(string $totp): void {
|
||||
private function showTotp(string $totp): void
|
||||
{
|
||||
$writer = new Writer(new PlainTextRenderer());
|
||||
file_put_contents(
|
||||
'php://stderr', <<<RAW
|
||||
'php://stderr',
|
||||
<<<RAW
|
||||
{$writer->writeString($totp)}
|
||||
$totp
|
||||
loading TOTP, because the env is not set, please copy above into TOTP_URI
|
||||
|
||||
RAW, FILE_APPEND
|
||||
RAW,
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user