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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
@@ -8,5 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
{{ include('_script.html.twig') }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user