From c45c63661473f390446221cf963e81dee37ae96e Mon Sep 17 00:00:00 2001 From: fs Date: Sat, 14 Mar 2026 15:57:29 +0100 Subject: [PATCH] feat(security): add Memcached-based rate limiting for remember-token auto-login Prevents brute-force attacks against remember-me cookies. Uses Memcached (Cache::add + increment) to track failures per IP: 5 attempts in 15 min window, auto-expiring via TTL. Counter resets on successful auto-login. Co-Authored-By: Claude Opus 4.6 --- lib/Service/Auth/AuthServicesFactory.php | 3 +- lib/Service/Auth/RememberMeRateLimiter.php | 49 ++++++++++++++++++++++ lib/Service/Auth/RememberMeService.php | 15 ++++++- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 lib/Service/Auth/RememberMeRateLimiter.php diff --git a/lib/Service/Auth/AuthServicesFactory.php b/lib/Service/Auth/AuthServicesFactory.php index a44125d..96e337c 100644 --- a/lib/Service/Auth/AuthServicesFactory.php +++ b/lib/Service/Auth/AuthServicesFactory.php @@ -80,7 +80,8 @@ class AuthServicesFactory $this->sessionStore, $this->cookieStore, $this->requestRuntime, - $this->authGatewayFactory->createAuthSettingsGateway() + $this->authGatewayFactory->createAuthSettingsGateway(), + new RememberMeRateLimiter() ); } diff --git a/lib/Service/Auth/RememberMeRateLimiter.php b/lib/Service/Auth/RememberMeRateLimiter.php new file mode 100644 index 0000000..5efb8d0 --- /dev/null +++ b/lib/Service/Auth/RememberMeRateLimiter.php @@ -0,0 +1,49 @@ += self::MAX_ATTEMPTS; + } + + public function recordFailure(string $ip): void + { + if ($ip === '') { + return; + } + + $key = self::KEY_PREFIX . $ip; + + // add() only sets if key doesn't exist — creates the window. + // If key already exists, add() returns false and we just increment. + if (Cache::add($key, 1, self::WINDOW_SECONDS)) { + return; + } + + Cache::increment($key); + } + + public function reset(string $ip): void + { + if ($ip === '') { + return; + } + + Cache::delete(self::KEY_PREFIX . $ip); + } +} diff --git a/lib/Service/Auth/RememberMeService.php b/lib/Service/Auth/RememberMeService.php index e25ccd2..54f3a3f 100644 --- a/lib/Service/Auth/RememberMeService.php +++ b/lib/Service/Auth/RememberMeService.php @@ -27,7 +27,8 @@ class RememberMeService private readonly SessionStoreInterface $sessionStore, private readonly CookieStoreInterface $cookieStore, private readonly RequestRuntimeInterface $requestRuntime, - private readonly ?AuthSettingsGateway $authSettingsGateway = null + private readonly ?AuthSettingsGateway $authSettingsGateway = null, + private readonly ?RememberMeRateLimiter $rateLimiter = null ) { } @@ -51,6 +52,14 @@ class RememberMeService if ($value === '' || strpos($value, ':') === false) { return false; } + + $ip = $this->requestRuntime->ip(); + + if ($this->rateLimiter !== null && $this->rateLimiter->isBlocked($ip)) { + $this->clearCookie(); + return false; + } + [$selector, $token] = explode(':', $value, 2); $selector = trim($selector); $token = trim($token); @@ -61,6 +70,7 @@ class RememberMeService $record = $this->rememberTokenRepository->findBySelector($selector); if (!$record) { + $this->rateLimiter?->recordFailure($ip); $this->clearCookie(); return false; } @@ -74,6 +84,7 @@ class RememberMeService $hash = (string) ($record['token_hash'] ?? ''); if ($hash === '' || !hash_equals($hash, hash('sha256', $token))) { + $this->rateLimiter?->recordFailure($ip); $this->rememberTokenRepository->deleteById((int) $record['id']); $this->clearCookie(); return false; @@ -120,6 +131,8 @@ class RememberMeService $this->rememberTokenRepository->updateToken((int) $record['id'], $newHash, $newExpires); $this->setCookie($selector, $newToken); + $this->rateLimiter?->reset($ip); + return true; }