First draft of backup codes. only created when the command is called. no command yet to expire/review codes.

Also added a few safeguards against excessively long user input.

Started on ability to redirect to auth subdomain (incomplete).
This commit is contained in:
2026-05-21 12:15:35 -04:00
parent 6ed1ab26f1
commit 38124ef66c
12 changed files with 242 additions and 26 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
/.idea/
###> symfony/framework-bundle ###
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
Generated
+6 -6
View File
@@ -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",
+6
View File
@@ -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)%'
+7
View File
@@ -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
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\BackupCodeManager;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
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 {
// Symfony will use this name if not overridden in configure().
protected static string $defaultName = 'app:generate-backup-codes';
private BackupCodeManager $manager;
public function __construct(BackupCodeManager $manager) {
parent::__construct();
$this->manager = $manager;
}
protected function configure(): void {
// Explicitly set the command name to avoid emptyname errors on older Symfony versions.
$this->setName('app:generate-backup-codes');
$this
->setDescription('Generate singleuse 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;
}
}
+21
View File
@@ -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;
}
}
+4 -3
View File
@@ -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);
}
+21 -8
View File
@@ -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']
));
}
}
}
}
+20 -6
View File
@@ -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(),
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\MonitorCacheKeys;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Trait\GetTotpTrait;
/**
* Manages generation and validation of singleuse backup codes.
*
* Backup codes are caseinsensitive alphanumeric strings whose length is
* the length of the TOTP code plus two characters. They are stored in the
* cache. Each code is marked as used after a successful authentication.
*/
final class BackupCodeManager {
use GetTotpTrait;
use StringTrait;
private const DEFAULT_COUNT = 10;
/* php base_convert() will break if given too long of an input */
const MAX_LENGTH = 64;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $sessionCache) {
$this->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<string> 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();
}
}
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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);
}
}