chore: add php-cs-fixer with PSR-12 config and CI check
Sync GitHub / sync (push) Failing after 5s
Tests / test (pull_request) Successful in 49s

- 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:
2026-08-11 08:30:05 -04:00
parent 6b5a711fa9
commit cb378e20bc
58 changed files with 2402 additions and 481 deletions
+5 -2
View File
@@ -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();
}
}
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+8 -4
View File
@@ -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");
+7 -3
View File
@@ -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)) {
+16 -7
View File
@@ -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(
+19 -11
View File
@@ -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;
+10 -5
View File
@@ -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
View File
@@ -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;
+7 -3
View File
@@ -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 -2
View File
@@ -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
+18 -9
View File
@@ -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 -1
View File
@@ -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;
+17 -9
View File
@@ -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);
}
+2 -1
View File
@@ -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;
}
+11 -6
View File
@@ -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");
+9 -4
View File
@@ -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;
}
}
+9 -4
View File
@@ -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;
+5 -2
View File
@@ -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;
}
}
+7 -3
View File
@@ -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
+5 -2
View File
@@ -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
View File
@@ -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
);
}
}