diff --git a/php/src/Auth/RateLimiter.php b/php/src/Auth/RateLimiter.php new file mode 100644 index 00000000..57e02de0 --- /dev/null +++ b/php/src/Auth/RateLimiter.php @@ -0,0 +1,44 @@ +getKey($ip)); + return $attempts !== false && (int)$attempts >= self::MAX_ATTEMPTS; + } + + /** + * Records a failed login attempt for the given IP. + * Uses a 15-minute sliding window: the first failure starts the window and + * subsequent failures within that window are counted together. + */ + public function recordFailedAttempt(string $ip): void { + $key = $this->getKey($ip); + // apcu_add only stores when the key does not yet exist. + // If it already exists (returns false), we increment the existing counter. + if (!apcu_add($key, 1, self::WINDOW_SECONDS)) { + apcu_inc($key); + } + } + + /** + * Clears the failed-attempt counter for the given IP, e.g. after a + * successful login. + */ + public function resetAttempts(string $ip): void { + apcu_delete($this->getKey($ip)); + } + + private function getKey(string $ip): string { + return 'login_attempts_' . hash('sha256', $ip); + } +} diff --git a/php/src/Controller/LoginController.php b/php/src/Controller/LoginController.php index d37a2210..e3279579 100644 --- a/php/src/Controller/LoginController.php +++ b/php/src/Controller/LoginController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace AIO\Controller; use AIO\Auth\AuthManager; +use AIO\Auth\RateLimiter; use AIO\Container\Container; use AIO\ContainerDefinitionFetcher; use AIO\Docker\DockerActionManager; @@ -14,20 +15,33 @@ readonly class LoginController { public function __construct( private AuthManager $authManager, private DockerActionManager $dockerActionManager, + private RateLimiter $rateLimiter, ) { } public function TryLogin(Request $request, Response $response, array $args) : Response { + $ip = (string)($request->getServerParams()['REMOTE_ADDR'] ?? ''); + + if ($this->rateLimiter->isLimitReached($ip)) { + $response->getBody()->write("Too many failed login attempts. Please try again later."); + return $response + ->withHeader('Retry-After', (string)RateLimiter::WINDOW_SECONDS) + ->withStatus(429); + } + if (!$this->dockerActionManager->isLoginAllowed()) { $response->getBody()->write("The login is blocked since Nextcloud is running."); return $response->withHeader('Location', '.')->withStatus(422); } $password = $request->getParsedBody()['password'] ?? ''; if($this->authManager->CheckCredentials($password)) { + $this->rateLimiter->resetAttempts($ip); $this->authManager->SetAuthState(true); return $response->withHeader('Location', '.')->withStatus(201); } + $this->rateLimiter->recordFailedAttempt($ip); + // Punish failed auth attempts with a delay, as a very simple means against bots. sleep(5); @@ -36,12 +50,23 @@ readonly class LoginController { } public function GetTryLogin(Request $request, Response $response, array $args) : Response { + $ip = (string)($request->getServerParams()['REMOTE_ADDR'] ?? ''); + + if ($this->rateLimiter->isLimitReached($ip)) { + return $response + ->withHeader('Retry-After', (string)RateLimiter::WINDOW_SECONDS) + ->withStatus(429); + } + $token = $request->getQueryParams()['token'] ?? ''; if($this->authManager->CheckToken($token)) { + $this->rateLimiter->resetAttempts($ip); $this->authManager->SetAuthState(true); return $response->withHeader('Location', '../..')->withStatus(302); } + $this->rateLimiter->recordFailedAttempt($ip); + // Punish failed auth attempts with a delay, as a very simple means against bots. sleep(5); diff --git a/php/src/DependencyInjection.php b/php/src/DependencyInjection.php index a7035a96..0c89b87d 100644 --- a/php/src/DependencyInjection.php +++ b/php/src/DependencyInjection.php @@ -43,6 +43,10 @@ class DependencyInjection \AIO\Auth\AuthManager::class, new \AIO\Auth\AuthManager($container->get(\AIO\Data\ConfigurationManager::class)) ); + $container->set( + \AIO\Auth\RateLimiter::class, + new \AIO\Auth\RateLimiter() + ); $container->set( \AIO\Data\Setup::class, new \AIO\Data\Setup(