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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user