rate-limiting update, now using a compound sliding-window. Continuing to move over to using traits more, and other code cleanup.

This commit is contained in:
2026-03-11 08:27:27 -04:00
parent 3d28485921
commit a0dc1a6049
17 changed files with 268 additions and 179 deletions
+1
View File
@@ -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.*",
Generated
+146 -1
View File
@@ -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",
+2 -4
View File
@@ -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
+15
View File
@@ -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]
+1 -1
View File
@@ -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?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
+5 -9
View File
@@ -16,12 +16,11 @@ parameters:
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
env(COOKIE_TTL): '2592000' # default 30 days
# rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
# number of consecutive failed login attempts before we block the ip address
env(LIMIT): '4' # default 4 failed login attempts before blocking
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
env(LIMIT_TIMEOUT): '21600' # default 6 hours
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
env(LIMIT_TTL): '86400' # default 24 hours
# default is the lower of 2 per half-minute or 10 per hour
env(BURST_COUNT): 2 # 2 per 30 seconds
env(BURST_TIME): 30 # seconds
env(UPPER_COUNT): 10 # 10 per hour
env(UPPER_TIME): 3600 # seconds (1 hour)
# --- extra variables ---
# query parameter prefix to prevent collisions
@@ -50,9 +49,6 @@ parameters:
env(TOO_MANY_MESSAGE): 'Try again later'
app.cookie_ttl: '%env(COOKIE_TTL)%'
app.limit: '%env(LIMIT)%'
app.limit_timeout: '%env(LIMIT_TIMEOUT)%'
app.limit_ttl: '%env(LIMIT_TTL)%'
app.query_prefix: '%env(QUERY_PREFIX)%'
app.totp_uri: '%env(TOTP_URI)%'
+5 -8
View File
@@ -11,14 +11,11 @@
# NOTE: rate limiting can *NOT* be disabled,
# but you could allow hundreds of logins a second
# number of consecutive failed login attempts before we block the ip address
#LIMIT=4 # default 4 failed login attempts before blocking
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
#LIMIT_TIMEOUT=21600 # default 6 hours
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
#LIMIT_TTL=86400 # default 24 hours
# 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
#UPPER_COUNT=10 # 10 per hour
#UPPER_TIME=3600 # seconds (1 hour)
# --- Extra Options ---
-21
View File
@@ -10,9 +10,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ConfigBag {
private ClockInterface $clock;
private int $cookieTtl;
private int $limit;
private int $limitTimeout;
private int $limitTtl;
private string $queryPrefix;
private string $totpUri;
private ?int $ipTtl;
@@ -26,9 +23,6 @@ final readonly class ConfigBag {
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.limit%')] int $limit,
#[Autowire('%app.limit_timeout%')] int $limitTimeout,
#[Autowire('%app.limit_ttl%')] int $limitTtl,
#[Autowire('%app.query_prefix%')] string $queryPrefix,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
@@ -39,9 +33,6 @@ final readonly class ConfigBag {
) {
$this->clock = $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";
}
+2 -2
View File
@@ -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 */
+2 -2
View File
@@ -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 */
+2 -4
View File
@@ -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 */
+11 -26
View File
@@ -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 */
+22 -23
View File
@@ -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']
));
}
}
}
+1 -26
View File
@@ -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());
+52
View File
@@ -0,0 +1,52 @@
<script>
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
form.addEventListener('submit', (event) => {
event.preventDefault();
/* make base64url string containing our payload json object */
const data = btoa(JSON.stringify({
id: form.preauth_id.value,
token: form.preauth_token.value,
nonce: form.preauth_nonce.value,
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/* send our request to the server */
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
}).then((response) => {
if (response.headers.has('Location')) {
/* follow redirect (not needed in most browsers) */
window.location.href = response.headers.get('Location');
} else if (response.headers.get('Content-Type') === 'application/json') {
/* got json, update the page */
response.json().then((content) => {
if (Object.hasOwn(content, 'message')) {
message.innerText = content.message;
}
if (Object.hasOwn(content, 'nonce')) {
form.preauth_nonce.value = content.nonce;
form.preauth_token.value = '';
form.preauth_token.focus();
}
}).catch((error) => {
console.log('failed to parse json from response');
console.log(error);
});
} else { /* non-json, non-redirect response */
/* overwrite the page */
response.text().then((text) => {
document.open();
document.write(text);
document.close();
}).catch((error) => {
console.log('failed to get text from response');
console.log(error);
});
}
});
});
</script>
+1
View File
@@ -8,5 +8,6 @@
</head>
<body>
{% block content %}{% endblock %}
{{ include('_script.html.twig') }}
</body>
</html>
-52
View File
@@ -13,56 +13,4 @@
autocomplete="one-time-code" required="required"></div>
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
<script>
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
form.addEventListener('submit', (event) => {
event.preventDefault();
/* make base64url string containing our payload json object */
const data = btoa(JSON.stringify({
id: form.preauth_id.value,
token: form.preauth_token.value,
nonce: form.preauth_nonce.value,
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/* send our request to the server */
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
}).then((response) => {
if (response.headers.has('Location')) {
/* follow redirect (not needed in most browsers) */
window.location.href = response.headers.get('Location');
} else if (response.headers.get('Content-Type') === 'application/json') {
/* got json, update the page */
response.json().then((content) => {
if (Object.hasOwn(content, 'message')) {
message.innerText = content.message;
}
if (Object.hasOwn(content, 'nonce')) {
form.preauth_nonce.value = content.nonce;
form.preauth_token.value = '';
form.preauth_token.focus();
}
}).catch((error) => {
console.log('failed to parse json from response');
console.log(error);
});
} else { /* non-json, non-redirect response */
/* overwrite the page */
response.text().then((text) => {
document.open();
document.write(text);
document.close();
}).catch((error) => {
console.log('failed to get text from response');
console.log(error);
});
}
});
});
</script>
{% endblock %}