diff --git a/.gitignore b/.gitignore index 1582e75..f2bad8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ - +/.idea/ ###> symfony/framework-bundle ### /config/secrets/prod/prod.decrypt.private.php /public/bundles/ diff --git a/composer.lock b/composer.lock index 6f43366..fe17ea2 100644 --- a/composer.lock +++ b/composer.lock @@ -2537,16 +2537,16 @@ }, { "name": "symfony/rate-limiter", - "version": "v7.4.7", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/rate-limiter.git", - "reference": "c2ff01c8d5ed54f0721f046fde14a94f2df09666" + "reference": "778c5239c7fd6bf9b886dedf3d84ddb156ddb888" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/c2ff01c8d5ed54f0721f046fde14a94f2df09666", - "reference": "c2ff01c8d5ed54f0721f046fde14a94f2df09666", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/778c5239c7fd6bf9b886dedf3d84ddb156ddb888", + "reference": "778c5239c7fd6bf9b886dedf3d84ddb156ddb888", "shasum": "" }, "require": { @@ -2587,7 +2587,7 @@ "rate-limiter" ], "support": { - "source": "https://github.com/symfony/rate-limiter/tree/v7.4.7" + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.10" }, "funding": [ { @@ -2607,7 +2607,7 @@ "type": "tidelift" } ], - "time": "2026-03-04T13:54:41+00:00" + "time": "2026-05-04T13:25:50+00:00" }, { "name": "symfony/routing", diff --git a/config/services.yaml b/config/services.yaml index c3f750e..7041d25 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -15,6 +15,10 @@ parameters: env(TOTP_URI): '' # blank to have the app generate one at random # how long will someone stay logged in, measured in seconds, zero for DEFAULT env(COOKIE_TTL): '2592000' # default 30 days + # Enable optional redirection to a dedicated authentication subdomain + env(SUBDOMAIN_REDIRECT): '0' # boolean, 1 to enable + # The subdomain (e.g., auth.example.com) to which unauthenticated users are redirected + env(AUTH_SUBDOMAIN): '' # rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second # default is the lower of 2 per half-minute or 10 per hour env(BURST_COUNT): 2 # 2 per 30 seconds @@ -51,6 +55,8 @@ parameters: app.cookie_ttl: '%env(COOKIE_TTL)%' app.query_prefix: '%env(QUERY_PREFIX)%' app.totp_uri: '%env(TOTP_URI)%' + app.subdomain_redirect: '%env(SUBDOMAIN_REDIRECT)%' + app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%' app.ip_ttl: '%env(IP_TTL)%' app.teapot: '%env(TEAPOT)%' diff --git a/docs/env.example b/docs/env.example index 14041f8..0a55976 100644 --- a/docs/env.example +++ b/docs/env.example @@ -11,6 +11,13 @@ # NOTE: rate limiting can *NOT* be disabled, # but you could allow hundreds of logins a second +# we can use a central auth, so that users only need to login once to have access to +# multiple services. Requires using sub-domains under the same domain. +# IE: if enabled have "service-one.example.com" redirect "auth.example.com", and after +# successful auth, user can visit "service-two.example.com" without having to login again. +#SUBDOMAIN_REDIRECT=false # default disabled, boolean +#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page + # rate limiting, default is the lower of 2 per 30 seconds or 10 per hour #BURST_COUNT=2 # 2 per 30 seconds #BURST_TIME=30 # seconds diff --git a/src/Command/GenerateBackupCodesCommand.php b/src/Command/GenerateBackupCodesCommand.php new file mode 100644 index 0000000..da2e6e0 --- /dev/null +++ b/src/Command/GenerateBackupCodesCommand.php @@ -0,0 +1,46 @@ +manager = $manager; + } + + protected function configure(): void { + // Explicitly set the command name to avoid empty‑name errors on older Symfony versions. + $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 { + $count = (int) $input->getArgument('count'); + $codes = $this->manager->generate($count); + foreach ($codes as $code) { + $output->writeln($code); + } + return Command::SUCCESS; + } +} diff --git a/src/ConfigBag.php b/src/ConfigBag.php index 3870390..cbfd8b1 100644 --- a/src/ConfigBag.php +++ b/src/ConfigBag.php @@ -17,6 +17,9 @@ final readonly class ConfigBag { private string $errorMessage; private string $teapotTitle; private string $tooManyTitle; + // New configuration for optional authentication subdomain redirection + private bool $subdomainRedirect; + private string $authSubdomain; /** @throws InvalidArgumentException */ public function __construct( @@ -30,6 +33,8 @@ final readonly class ConfigBag { #[Autowire('%app.error_message%')] string $errorMessage, #[Autowire('%app.teapot_title%')] string $teapotTitle, #[Autowire('%app.too_many_title%')] string $tooManyTitle, + #[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect, + #[Autowire('%app.auth_subdomain%')] string $authSubdomain, ) { $this->clock = $clock; $this->cookieTtl = $cookieTtl; @@ -40,6 +45,8 @@ final readonly class ConfigBag { $this->errorMessage = $errorMessage; $this->teapotTitle = $teapotTitle; $this->tooManyTitle = $tooManyTitle; + $this->subdomainRedirect = $subdomainRedirect; + $this->authSubdomain = $authSubdomain; } public function clock(): ClockInterface { @@ -77,4 +84,18 @@ final readonly class ConfigBag { public function tooManyTitle(): string { return $this->tooManyTitle; } + + /** + * Whether the application should redirect unauthenticated requests to a dedicated authentication subdomain. + */ + public function subdomainRedirect(): bool { + return $this->subdomainRedirect; + } + + /** + * The subdomain (e.g., "auth.example.com") to which unauthenticated requests should be redirected. + */ + public function authSubdomain(): string { + return $this->authSubdomain; + } } diff --git a/src/Data/Payload.php b/src/Data/Payload.php index 39feced..9886d59 100644 --- a/src/Data/Payload.php +++ b/src/Data/Payload.php @@ -39,12 +39,13 @@ final class Payload { return null; } + /* all input is limited */ $payload = new Payload(); - $payload->id = $data->id; - $payload->nonce = $data->nonce; + $payload->id = mb_substr($data->id, 0, 128); + $payload->nonce = mb_substr($data->nonce, 0, 128); $payload->json = ($data->json ?? true); $payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie; - $payload->token = $data->token; + $payload->token = mb_substr($data->token, 0, 128); return Payload::constrict($payload); } diff --git a/src/Listener/InterceptListener.php b/src/Listener/InterceptListener.php index 38b517f..a3b00ad 100644 --- a/src/Listener/InterceptListener.php +++ b/src/Listener/InterceptListener.php @@ -30,14 +30,27 @@ final readonly class InterceptListener { if ($event->getRequest()) { /* by this point, we know that the request we have is: * not already authorized, nor already rate-limited, - * nor submitting login credentials; so present the login page now */ - $this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}"); - $content = $this->twig->render('login.html.twig', [ - 'nonce' => $this->makeNonce(), - ]); - $event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED, - ['Content-Type' => 'text/html'] - )); + * nor submitting login credentials; so redirect or present the login page now */ + if ($this->config->subdomainRedirect() && $this->config->authSubdomain() && + $this->config->authSubdomain() !== $event->getRequest()->getHost() + ) { + // TODO verify host has the same base of the authSubdomain + /* redirect to auth */ + $query = http_build_query([ + $this->config->query('return') => $event->getRequest()->getUri(), + ]); + $event->setResponse(new Response('', Response::HTTP_TEMPORARY_REDIRECT, + ['Location' => "https://{$this->config->authSubdomain()}/?$query"] + )); + } else { + $this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}"); + $content = $this->twig->render('login.html.twig', [ + 'nonce' => $this->makeNonce(), + ]); + $event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED, + ['Content-Type' => 'text/html'] + )); + } } } } diff --git a/src/Listener/LoginListener.php b/src/Listener/LoginListener.php index 45cb3c3..066b569 100644 --- a/src/Listener/LoginListener.php +++ b/src/Listener/LoginListener.php @@ -6,6 +6,7 @@ namespace App\Listener; use App\Data\Payload; use App\Enum\Scope; use App\MonitorCacheKeys; +use App\Service\BackupCodeManager; use App\Trait\CookieNameTrait; use App\Trait\GetTotpTrait; use App\Trait\HasLoggerTrait; @@ -36,15 +37,18 @@ final readonly class LoginListener { private CacheItemPoolInterface $sessionCache; private RateLimiterFactoryInterface $rateLimiter; + private BackupCodeManager $backupCodeManager; /** @throws InvalidArgumentException */ public function __construct( private Environment $twig, CacheItemPoolInterface $sessionCache, #[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter, + BackupCodeManager $backupCodeManager, ) { $this->sessionCache = new MonitorCacheKeys($sessionCache); $this->rateLimiter = $rateLimiter; + $this->backupCodeManager = $backupCodeManager; } /** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */ @@ -79,11 +83,13 @@ final readonly class LoginListener { $payload->scope = Scope::Cookie; } - if ($this->getTotp()->verify($payload->token, null, 10)) { - /* token is correct */ + if ($this->getTotp()->verify($payload->token, null, 10) || + $this->backupCodeManager->verifyAndConsume($payload->token) + ) { + /* token is correct (TOTP or Backup) */ /* if server nonce is found and is valid */ - $nonceItem = $this->nonceCache->getItem($payload->nonce); + $nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce)); if ($nonceItem->isHit() && $nonceItem->get()) { /* mark nonce as spent */ $nonceItem->set(false); /* invalid */ @@ -117,11 +123,13 @@ final readonly class LoginListener { $content = "hi $cleanId, please reload"; } + $location = $request->query->has('return') && $this->validateUrl($request->query->get('return')) ? + "{$request->query->get('return')}" : + "{$request->getPathInfo()}{$request->getQueryString()}"; + $response->setContent($content) ->setStatusCode(Response::HTTP_TEMPORARY_REDIRECT) - ->headers->set('Location', - "{$request->getPathInfo()}{$request->getQueryString()}" - ); + ->headers->set('Location', $location); $response->headers->set('Content-Type', $contentType); } @@ -132,6 +140,11 @@ final readonly class LoginListener { return null; } + private function validateUrl(string $url): bool { + // TODO verify host has the same base of the authSubdomain + return filter_var($url, FILTER_VALIDATE_URL); + } + /** @throws InvalidArgumentException */ private function setCookie(string $id): Cookie { /* successful auth with token, store session and set the cookie */ @@ -148,6 +161,7 @@ final readonly class LoginListener { $sessionCookie->expiresAfter($this->config->cookieTtl()); $this->sessionCache->save($sessionCookie); + // TODO if using authSubdomain, the cookie we issue will need to be different.. different prefix, domain being specified, etc. return Cookie::create( name: $this->cookieName(), value: $ulid->toString(), diff --git a/src/Service/BackupCodeManager.php b/src/Service/BackupCodeManager.php new file mode 100644 index 0000000..2c8b557 --- /dev/null +++ b/src/Service/BackupCodeManager.php @@ -0,0 +1,108 @@ +sessionCache = new MonitorCacheKeys($sessionCache); + } + + /** + * Generate a set of backup codes for a given user identifier. + * + * @param int $count Number of codes to generate + * @return list Generated backup codes + * @throws InvalidArgumentException|Exception + */ + 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++) { + /* output is alphanumeric string of given length */ + $codes[] = str_pad(substr(base_convert(bin2hex(random_bytes($length)), + 16, 36), 0, $length), + $length, '0', STR_PAD_LEFT); + } + $this->saveCodes($codes); + return $codes; + } + + /** @throws InvalidArgumentException */ + public function expire(): void { + $itemsToRemove = []; + foreach ($this->sessionCache->getKeys() as $key) { + if (str_starts_with($key, 'backup_')) { + $itemsToRemove[] = $key; + } + } + if (count($itemsToRemove) > 0) { + $this->sessionCache->deleteItems($itemsToRemove); + } + } + + /** + * Verify a backup code and, if valid, mark it as used. + * + * @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 { + $backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code"))); + if ($backupItem->isHit() && $backupItem->get()) { + /* mark backup code as spent */ + $backupItem->set(false); /* used */ + /* 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' + )); + $this->sessionCache->save($backupItem); + + return true; + } + return false; + } + + /** @throws InvalidArgumentException */ + private function saveCodes(array $codes): void { + foreach ($codes as $code) { + $backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code"))); + /* mark backup code as ready */ + $backupItem->set(true); + /* 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' + )); + $this->sessionCache->saveDeferred($backupItem); + } + $this->sessionCache->commit(); + } +} diff --git a/src/Trait/MakeNonceTrait.php b/src/Trait/MakeNonceTrait.php index 51f2816..d8a1eb0 100644 --- a/src/Trait/MakeNonceTrait.php +++ b/src/Trait/MakeNonceTrait.php @@ -30,7 +30,7 @@ trait MakeNonceTrait { $nonce = rtrim(strtr(base64_encode(random_bytes( static::NONCE_LENGTH )), '+/', '-_'), '='); - $nonceItem = $this->nonceCache->getItem($nonce); + $nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce)); if ($nonceItem->isHit()) { if ($retries < 1) { diff --git a/src/Trait/StringTrait.php b/src/Trait/StringTrait.php index 5f9e96a..79e7765 100644 --- a/src/Trait/StringTrait.php +++ b/src/Trait/StringTrait.php @@ -8,6 +8,6 @@ trait StringTrait { private const KEY_REGEX = '/[^A-Za-z0-9_.]+/'; public function makeCacheKey(string $name): string { - return preg_replace(static::KEY_REGEX, '_', $name); + return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128); } }