feat: add proper rate limiting for login endpoints

- Add RateLimiter class using APCu for per-IP tracking
- Limit failed login attempts to 10 per 15-minute window
- Apply rate limiting in TryLogin (POST /api/auth/login) and GetTryLogin (GET /api/auth/getlogin)
- Return 429 Too Many Requests with Retry-After header when limit exceeded
- Reset counter on successful login
- Register RateLimiter in DependencyInjection container

Agent-Logs-Url: https://github.com/nextcloud/all-in-one/sessions/1ce6e415-a2b7-478c-955f-72f582c157c4

Co-authored-by: szaimen <42591237+szaimen@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-12 10:10:40 +00:00
committed by GitHub
parent 15ae285d9f
commit a466cd4284
3 changed files with 73 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace AIO\Auth;
class RateLimiter {
public const int MAX_ATTEMPTS = 10;
public const int WINDOW_SECONDS = 900; // 15 minutes
/**
* Returns true when the IP has exceeded the maximum number of failed login
* attempts within the current time window and should be blocked.
*/
public function isLimitReached(string $ip): bool {
$attempts = apcu_fetch($this->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);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace AIO\Controller; namespace AIO\Controller;
use AIO\Auth\AuthManager; use AIO\Auth\AuthManager;
use AIO\Auth\RateLimiter;
use AIO\Container\Container; use AIO\Container\Container;
use AIO\ContainerDefinitionFetcher; use AIO\ContainerDefinitionFetcher;
use AIO\Docker\DockerActionManager; use AIO\Docker\DockerActionManager;
@@ -14,20 +15,33 @@ readonly class LoginController {
public function __construct( public function __construct(
private AuthManager $authManager, private AuthManager $authManager,
private DockerActionManager $dockerActionManager, private DockerActionManager $dockerActionManager,
private RateLimiter $rateLimiter,
) { ) {
} }
public function TryLogin(Request $request, Response $response, array $args) : Response { 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()) { if (!$this->dockerActionManager->isLoginAllowed()) {
$response->getBody()->write("The login is blocked since Nextcloud is running."); $response->getBody()->write("The login is blocked since Nextcloud is running.");
return $response->withHeader('Location', '.')->withStatus(422); return $response->withHeader('Location', '.')->withStatus(422);
} }
$password = $request->getParsedBody()['password'] ?? ''; $password = $request->getParsedBody()['password'] ?? '';
if($this->authManager->CheckCredentials($password)) { if($this->authManager->CheckCredentials($password)) {
$this->rateLimiter->resetAttempts($ip);
$this->authManager->SetAuthState(true); $this->authManager->SetAuthState(true);
return $response->withHeader('Location', '.')->withStatus(201); return $response->withHeader('Location', '.')->withStatus(201);
} }
$this->rateLimiter->recordFailedAttempt($ip);
// Punish failed auth attempts with a delay, as a very simple means against bots. // Punish failed auth attempts with a delay, as a very simple means against bots.
sleep(5); sleep(5);
@@ -36,12 +50,23 @@ readonly class LoginController {
} }
public function GetTryLogin(Request $request, Response $response, array $args) : Response { 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'] ?? ''; $token = $request->getQueryParams()['token'] ?? '';
if($this->authManager->CheckToken($token)) { if($this->authManager->CheckToken($token)) {
$this->rateLimiter->resetAttempts($ip);
$this->authManager->SetAuthState(true); $this->authManager->SetAuthState(true);
return $response->withHeader('Location', '../..')->withStatus(302); return $response->withHeader('Location', '../..')->withStatus(302);
} }
$this->rateLimiter->recordFailedAttempt($ip);
// Punish failed auth attempts with a delay, as a very simple means against bots. // Punish failed auth attempts with a delay, as a very simple means against bots.
sleep(5); sleep(5);

View File

@@ -43,6 +43,10 @@ class DependencyInjection
\AIO\Auth\AuthManager::class, \AIO\Auth\AuthManager::class,
new \AIO\Auth\AuthManager($container->get(\AIO\Data\ConfigurationManager::class)) new \AIO\Auth\AuthManager($container->get(\AIO\Data\ConfigurationManager::class))
); );
$container->set(
\AIO\Auth\RateLimiter::class,
new \AIO\Auth\RateLimiter()
);
$container->set( $container->set(
\AIO\Data\Setup::class, \AIO\Data\Setup::class,
new \AIO\Data\Setup( new \AIO\Data\Setup(