diff --git a/composer.json b/composer.json index 91c1fa0..ad1a7d3 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "symfony/flex": "^2.10", "symfony/framework-bundle": "7.4.*", "symfony/mime": "7.4.*", + "symfony/rate-limiter": "7.4.*", "symfony/runtime": "7.4.*", "symfony/twig-bundle": "7.4.*", "symfony/uid": "7.4.*", diff --git a/composer.lock b/composer.lock index 69fad7c..6f43366 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fc72cd4e297780175a284feb4d27063a", + "content-hash": "1399aeacc08847b58b9a2df2b729f243", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1962,6 +1962,77 @@ ], "time": "2026-03-05T15:24:09+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b38026df55197f9e39a44f3215788edf83187b80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:39:26+00:00" + }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.33.0", @@ -2464,6 +2535,80 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "c2ff01c8d5ed54f0721f046fde14a94f2df09666" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/c2ff01c8d5ed54f0721f046fde14a94f2df09666", + "reference": "c2ff01c8d5ed54f0721f046fde14a94f2df09666", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^7.3|^8.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-04T13:54:41+00:00" + }, { "name": "symfony/routing", "version": "v7.4.6", diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 307fc05..8c0dd1c 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -4,14 +4,12 @@ framework: pools: nonceCache: adapters: cache.adapter.apcu + rateLimitCache: + adapters: cache.adapter.apcu sessionCache: adapters: cache.adapter.apcu - requestCache: - adapters: cache.adapter.apcu sessionStorage: adapters: cache.adapter.filesystem - requestStorage: - adapters: cache.adapter.filesystem # Unique name of your app: used to compute stable namespaces for cache keys. prefix_seed: digitaladapt/preauth diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml new file mode 100644 index 0000000..80e70fe --- /dev/null +++ b/config/packages/rate_limiter.yaml @@ -0,0 +1,15 @@ +framework: + rate_limiter: + burst: + policy: 'sliding_window' + limit: '%env(int:BURST_COUNT)%' + interval: '%env(int:BURST_TIME)% seconds' + cache_pool: 'rateLimitCache' + upper: + policy: 'sliding_window' + limit: '%env(int:UPPER_COUNT)%' + interval: '%env(int:UPPER_TIME)% seconds' + cache_pool: 'rateLimitCache' + login_limiter: + policy: compound + limiters: [burst, upper] diff --git a/config/reference.php b/config/reference.php index 7c49104..515b64f 100644 --- a/config/reference.php +++ b/config/reference.php @@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * rate_limiter?: bool|array{ // Rate limiter configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * limiters?: arrayclock = $clock; $this->cookieTtl = $cookieTtl; - $this->limit = ($limit >= 1) ? $limit : 4; - $this->limitTimeout = ($limitTimeout >= 1) ? $limitTimeout : 21600; - $this->limitTtl = ($limitTtl >= 1) ? $limitTtl : 86400; $this->queryPrefix = $queryPrefix; $this->totpUri = $totpUri ?: $utilities->loadTotp(); $this->ipTtl = $ipTtl ?: null; @@ -59,18 +50,6 @@ final readonly class ConfigBag { return $this->cookieTtl; } - public function limit(): int { - return $this->limit; - } - - public function limitTimeout(): int { - return $this->limitTimeout; - } - - public function limitTtl(): int { - return $this->limitTtl; - } - public function query(string $field): string { return "$this->queryPrefix$field"; } diff --git a/src/Listener/AcceptListener.php b/src/Listener/AcceptListener.php index 7a222fb..c9f3c2f 100644 --- a/src/Listener/AcceptListener.php +++ b/src/Listener/AcceptListener.php @@ -4,21 +4,21 @@ declare(strict_types=1); namespace App\Listener; use App\Trait\CookieNameTrait; +use App\Trait\HasLoggerTrait; use App\Trait\StringTrait; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; -use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; final readonly class AcceptListener { use CookieNameTrait; + use HasLoggerTrait; use StringTrait; public function __construct( private CacheItemPoolInterface $sessionCache, - private LoggerInterface $logger, ) {} /** @throws InvalidArgumentException */ diff --git a/src/Listener/AllowListener.php b/src/Listener/AllowListener.php index bd6633f..dd2a6a3 100644 --- a/src/Listener/AllowListener.php +++ b/src/Listener/AllowListener.php @@ -4,21 +4,21 @@ declare(strict_types=1); namespace App\Listener; use App\ConfigBag; +use App\Trait\HasLoggerTrait; use App\Trait\StringTrait; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; -use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; final readonly class AllowListener { + use HasLoggerTrait; use StringTrait; public function __construct( private CacheItemPoolInterface $sessionCache, private ConfigBag $config, - private LoggerInterface $logger, ) {} /** @throws InvalidArgumentException */ diff --git a/src/Listener/InterceptListener.php b/src/Listener/InterceptListener.php index 7ffc1f3..38b517f 100644 --- a/src/Listener/InterceptListener.php +++ b/src/Listener/InterceptListener.php @@ -6,7 +6,6 @@ namespace App\Listener; use App\ConfigBag; use App\Trait\HasLoggerTrait; use App\Trait\MakeNonceTrait; -use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\Response; @@ -21,9 +20,8 @@ final readonly class InterceptListener { use MakeNonceTrait; public function __construct( - private CacheItemPoolInterface $requestCache, - private ConfigBag $config, - private Environment $twig, + private ConfigBag $config, + private Environment $twig, ) {} /** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */ diff --git a/src/Listener/LoginListener.php b/src/Listener/LoginListener.php index 5e42588..45cb3c3 100644 --- a/src/Listener/LoginListener.php +++ b/src/Listener/LoginListener.php @@ -13,12 +13,14 @@ use App\Trait\MakeNonceTrait; use App\Trait\StringTrait; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\Uid\Ulid; use Twig\Environment; use Twig\Error\LoaderError; @@ -32,17 +34,17 @@ final readonly class LoginListener { use StringTrait; use GetTotpTrait; - private CacheItemPoolInterface $requestCache; private CacheItemPoolInterface $sessionCache; + private RateLimiterFactoryInterface $rateLimiter; /** @throws InvalidArgumentException */ public function __construct( - private Environment $twig, - CacheItemPoolInterface $requestCache, - CacheItemPoolInterface $sessionCache, + private Environment $twig, + CacheItemPoolInterface $sessionCache, + #[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter, ) { - $this->requestCache = new MonitorCacheKeys($requestCache); $this->sessionCache = new MonitorCacheKeys($sessionCache); + $this->rateLimiter = $rateLimiter; } /** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */ @@ -62,10 +64,7 @@ final readonly class LoginListener { return; } - $limitReached = $this->logFailure( - $payload ? $payload->toString() : $data, - $event->getRequest() - ); + $limitReached = $this->logFailure($event->getRequest()); $this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}"); $event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true)); @@ -169,23 +168,9 @@ final readonly class LoginListener { $this->sessionCache->save($sessionIp); } - /** @throws InvalidArgumentException */ - private function logFailure(string $data, Request $request): bool { - // TODO use rate-limiting symfony system (also update RejectListener) - $timeframe = (int)floor(time() / $this->getTotp()->getPeriod()); - /* hash the data and timeframe, so we do not count duplicates in the same timeframe - * hitting refresh a few times should not lock you out */ - $ipKey = $this->makeCacheKey("ip_{$request->getClientIp()}"); - $failuresItem = $this->requestCache->getItem($ipKey); - $failures = $failuresItem->get() ?? []; - $failures[hash('xxh3', "$timeframe-$data")] = true; - $limitReached = count($failures) >= $this->config->limit(); - $failuresItem->set($failures); - $failuresItem->expiresAfter($limitReached - ? $this->config->limitTtl() : $this->config->limitTimeout() - ); - $this->requestCache->save($failuresItem); - return $limitReached; + private function logFailure(Request $request): bool { + $limiter = $this->rateLimiter->create($request->getClientIp()); + return ($limiter->consume(1)->getRemainingTokens() < 1); } /** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */ diff --git a/src/Listener/RejectListener.php b/src/Listener/RejectListener.php index e8876c2..643f09e 100644 --- a/src/Listener/RejectListener.php +++ b/src/Listener/RejectListener.php @@ -4,45 +4,44 @@ declare(strict_types=1); namespace App\Listener; use App\ConfigBag; +use App\Trait\HasLoggerTrait; use App\Trait\StringTrait; -use Psr\Cache\CacheItemPoolInterface; -use Psr\Cache\InvalidArgumentException; -use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Twig\Environment; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; final readonly class RejectListener { + use HasLoggerTrait; use StringTrait; - public function __construct( - private CacheItemPoolInterface $requestCache, - private ConfigBag $config, - private Environment $twig, - private LoggerInterface $logger, - ) {} + private RateLimiterFactoryInterface $rateLimiter; - /** @throws SyntaxError|InvalidArgumentException|RuntimeError|LoaderError */ + public function __construct( + private ConfigBag $config, + private Environment $twig, + #[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter, + ) { + $this->rateLimiter = $rateLimiter; + } + + /** @throws SyntaxError|RuntimeError|LoaderError */ #[AsEventListener(priority: 77)] public function onKernelRequest(RequestEvent $event): void { - $ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}"); - /* check if they have made too many failed login attempts */ - $failuresItem = $this->requestCache->getItem($ipKey); - if ($failuresItem->isHit()) { - $failures = $failuresItem->get() ?? []; - if (count($failures) >= $this->config->limit()) { - $this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}"); - $html = $this->twig->render('error.html.twig'); - $event->setResponse(new Response($html, ($this->config->teapot() - ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS), - ['Content-Type' => 'text/html'] - )); - } + $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() + ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS), + ['Content-Type' => 'text/html'] + )); } } } diff --git a/src/PersistCache.php b/src/PersistCache.php index 14bae22..f637f55 100644 --- a/src/PersistCache.php +++ b/src/PersistCache.php @@ -9,36 +9,21 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; #[Autoconfigure(public: true)] final readonly class PersistCache { - private MonitorCacheKeys $requestCache; - private MonitorCacheKeys $requestStorage; private MonitorCacheKeys $sessionCache; private MonitorCacheKeys $sessionStorage; /** @throws InvalidArgumentException */ public function __construct( - CacheItemPoolInterface $requestCache, - CacheItemPoolInterface $requestStorage, CacheItemPoolInterface $sessionCache, CacheItemPoolInterface $sessionStorage, ) { - $this->requestCache = new MonitorCacheKeys($requestCache); - $this->requestStorage = new MonitorCacheKeys($requestStorage); - $this->sessionCache = new MonitorCacheKeys($sessionCache); + $this->sessionCache = new MonitorCacheKeys($sessionCache); $this->sessionStorage = new MonitorCacheKeys($sessionStorage); } /** @throws InvalidArgumentException */ public function boot(): void { /* the caches are considered warm as soon as they are not empty */ - if (empty($this->requestCache->getKeys())) { - $items = $this->requestStorage->getItems($this->requestStorage->getKeys()); - foreach ($items as $item) { - $this->requestCache->saveDeferred($item); - } - $this->requestCache->markClean(); - $this->requestCache->commit(); - } - if (empty($this->sessionCache->getKeys())) { $items = $this->sessionStorage->getItems($this->sessionStorage->getKeys()); foreach ($items as $item) { @@ -52,16 +37,6 @@ final readonly class PersistCache { /** @throws InvalidArgumentException */ public function persist(): void { /* we only need to persist the caches if they contain changes */ - if ($this->requestCache->isDirty()) { - $this->requestCache->markClean(); - $items = $this->requestCache->getItems($this->requestCache->getKeys()); - $this->requestStorage->clear(); - foreach ($items as $item) { - $this->requestStorage->saveDeferred($item); - } - $this->requestStorage->commit(); - } - if ($this->sessionCache->isDirty()) { $this->sessionCache->markClean(); $items = $this->sessionCache->getItems($this->sessionCache->getKeys()); diff --git a/templates/_script.html.twig b/templates/_script.html.twig new file mode 100644 index 0000000..4983edc --- /dev/null +++ b/templates/_script.html.twig @@ -0,0 +1,52 @@ + diff --git a/templates/base.html.twig b/templates/base.html.twig index d99e1a0..b75e9ff 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -8,5 +8,6 @@ {% block content %}{% endblock %} +{{ include('_script.html.twig') }} diff --git a/templates/login.html.twig b/templates/login.html.twig index 7fcae01..332236f 100644 --- a/templates/login.html.twig +++ b/templates/login.html.twig @@ -13,56 +13,4 @@ autocomplete="one-time-code" required="required">
- {% endblock %}