From 238b932c86de4feff9cabe0d64fce85fe21967ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sun, 12 Jul 2026 09:11:47 +0900 Subject: [PATCH] fix(cloud): add per-IP rate limit to /login before Argon2id verify (H2) Every login attempt, including unknown users (via DUMMY_HASH), forces a full Argon2id verify with no rate limit, letting a flood take down the login endpoint / shared host via hashing-cost amplification. Add a pre-auth per-IP limiter (RateLimiterRepository, default 20/min) as the first check in AuthController::login, before any user lookup or password hashing. --- cloud/public/index.php | 4 +-- cloud/src/Auth/AuthController.php | 12 +++++++-- cloud/tests/AuthControllerTest.php | 43 ++++++++++++++++++------------ 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/cloud/public/index.php b/cloud/public/index.php index f5b9ba6..07093da 100644 --- a/cloud/public/index.php +++ b/cloud/public/index.php @@ -32,7 +32,7 @@ $audit = new PdoAuditRepository($pdo); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); $config = new ConfigController($devices, $zones, $thr); $heartbeat = new HeartbeatController($cfg, $devices); -$auth = new AuthController($users, $audit); +$auth = new AuthController($users, $audit, $rate); $list = new EventListController($events, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess); $proxy = new SnapshotProxy($snaps, $sess); @@ -47,7 +47,7 @@ $r = new Router(); $r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now)); $r->add('GET', '/api/config', fn(Request $q) => $config->get($q)); -$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now)); +$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); $r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); diff --git a/cloud/src/Auth/AuthController.php b/cloud/src/Auth/AuthController.php index 6f1b438..9382884 100644 --- a/cloud/src/Auth/AuthController.php +++ b/cloud/src/Auth/AuthController.php @@ -4,7 +4,7 @@ namespace App\Auth; use App\Http\{Request, Response}; use App\Security\Password; -use App\Repository\{UserRepository, AuditRepository}; +use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository}; final class AuthController { @@ -14,12 +14,20 @@ final class AuthController public function __construct( private UserRepository $users, private AuditRepository $audit, + private RateLimiterRepository $rateLimiter, private int $maxAttempts = 5, private int $lockSeconds = 900, + private int $loginIpLimitPerMin = 20, ) {} - public function login(Request $req, SessionStore $sess, int $now): Response + public function login(Request $req, SessionStore $sess, int $now, string $clientIp): Response { + // Pre-auth per-IP limit: fires before any user lookup / Argon2id verify, + // so a flood can't force repeated expensive hash work (DoS amplification). + if ($clientIp !== '' && $this->rateLimiter->hit('login:' . $clientIp, intdiv($now, 60)) > $this->loginIpLimitPerMin) { + return Response::json(['error' => 'rate_limited'], 429); + } + $username = (string)$req->post('username'); $password = (string)$req->post('password'); $u = $this->users->byUsername($username); diff --git a/cloud/tests/AuthControllerTest.php b/cloud/tests/AuthControllerTest.php index 53537e8..8a39225 100644 --- a/cloud/tests/AuthControllerTest.php +++ b/cloud/tests/AuthControllerTest.php @@ -4,7 +4,7 @@ namespace App\Tests; use App\Http\Request; use App\Security\Password; use App\Auth\{AuthController, ArraySessionStore}; -use App\Tests\Support\{InMemoryUserRepository, InMemoryAuditRepository}; +use App\Tests\Support\{InMemoryUserRepository, InMemoryAuditRepository, InMemoryRateLimiter}; use PHPUnit\Framework\TestCase; final class AuthControllerTest extends TestCase @@ -19,52 +19,61 @@ final class AuthControllerTest extends TestCase return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]); } public function testLoginSuccessSetsSession(): void { - $c = new AuthController($this->users(), new InMemoryAuditRepository()); + $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); $sess = new ArraySessionStore(); - $res = $c->login($this->req('alice', 'pw'), $sess, now: 1000); + $res = $c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4'); $this->assertSame(200, $res->status()); $this->assertSame(1, $sess->get('user_id')); $this->assertSame('operator', $sess->get('role')); } public function testBadPasswordIs401(): void { - $c = new AuthController($this->users(), new InMemoryAuditRepository()); - $this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000)->status()); + $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); + $this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status()); } public function testLockoutAfterMaxAttempts(): void { $repo = $this->users(); - $c = new AuthController($repo, new InMemoryAuditRepository(), maxAttempts: 3, lockSeconds: 900); - for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000); + $c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900); + for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4'); // now locked, even correct password is 423 - $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000)->status()); + $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status()); } public function testExpiredLockResetsAttempts(): void { $repo = $this->users(); - $c = new AuthController($repo, new InMemoryAuditRepository(), maxAttempts: 3, lockSeconds: 900); + $c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900); // lock the account with 3 bad tries at now=1000 - for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000); + for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4'); // locked_until is now 1000 + 900 = 1900, verify it's locked - $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000)->status()); + $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status()); // now at now=2000 (after lock window expires at 1900), login with correct password should succeed $sess = new ArraySessionStore(); - $res = $c->login($this->req('alice', 'pw'), $sess, now: 2000); + $res = $c->login($this->req('alice', 'pw'), $sess, now: 2000, clientIp: '1.2.3.4'); $this->assertSame(200, $res->status()); $this->assertSame(1, $sess->get('user_id')); } public function testUnknownUsernameReturns401GenericBody(): void { - $c = new AuthController($this->users(), new InMemoryAuditRepository()); - $res = $c->login($this->req('nobody', 'whatever'), new ArraySessionStore(), now: 1000); + $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); + $res = $c->login($this->req('nobody', 'whatever'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4'); $this->assertSame(401, $res->status()); // identical generic body to the wrong-password case (no user-enumeration leak) - $wrong = $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000); + $wrong = $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4'); $this->assertSame($wrong->body(), $res->body()); } public function testLogoutClearsSession(): void { - $c = new AuthController($this->users(), new InMemoryAuditRepository()); + $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); $sess = new ArraySessionStore(); - $c->login($this->req('alice', 'pw'), $sess, now: 1000); + $c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4'); $this->assertSame(1, $sess->get('user_id')); $res = $c->logout($sess); $this->assertSame(200, $res->status()); $this->assertNull($sess->get('user_id')); // session identity cleared } + public function testLoginIpRateLimited(): void { + // Even with correct credentials, floods from a single IP must be + // throttled before the (expensive) Argon2id verify runs repeatedly. + $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter(), loginIpLimitPerMin: 2); + $ip = '9.9.9.9'; + $this->assertSame(200, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status()); + $this->assertSame(200, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status()); + $this->assertSame(429, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status()); + } }