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
-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());