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:
+1
-1
@@ -1,4 +1,4 @@
|
||||
|
||||
/.idea/
|
||||
###> symfony/framework-bundle ###
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
|
||||
Generated
+6
-6
@@ -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",
|
||||
|
||||
@@ -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)%'
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 single‑use backup codes.
|
||||
*
|
||||
* Backup codes are case‑insensitive 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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user