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.
This commit is contained in:
유창욱 2026-07-12 09:11:47 +09:00
parent 7828f9c419
commit 238b932c86
3 changed files with 38 additions and 21 deletions

View file

@ -32,7 +32,7 @@ $audit = new PdoAuditRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr); $config = new ConfigController($devices, $zones, $thr);
$heartbeat = new HeartbeatController($cfg, $devices); $heartbeat = new HeartbeatController($cfg, $devices);
$auth = new AuthController($users, $audit); $auth = new AuthController($users, $audit, $rate);
$list = new EventListController($events, $sess); $list = new EventListController($events, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $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/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('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now));
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q)); $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('POST', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); $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)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));

View file

@ -4,7 +4,7 @@ namespace App\Auth;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Password; use App\Security\Password;
use App\Repository\{UserRepository, AuditRepository}; use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository};
final class AuthController final class AuthController
{ {
@ -14,12 +14,20 @@ final class AuthController
public function __construct( public function __construct(
private UserRepository $users, private UserRepository $users,
private AuditRepository $audit, private AuditRepository $audit,
private RateLimiterRepository $rateLimiter,
private int $maxAttempts = 5, private int $maxAttempts = 5,
private int $lockSeconds = 900, 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'); $username = (string)$req->post('username');
$password = (string)$req->post('password'); $password = (string)$req->post('password');
$u = $this->users->byUsername($username); $u = $this->users->byUsername($username);

View file

@ -4,7 +4,7 @@ namespace App\Tests;
use App\Http\Request; use App\Http\Request;
use App\Security\Password; use App\Security\Password;
use App\Auth\{AuthController, ArraySessionStore}; use App\Auth\{AuthController, ArraySessionStore};
use App\Tests\Support\{InMemoryUserRepository, InMemoryAuditRepository}; use App\Tests\Support\{InMemoryUserRepository, InMemoryAuditRepository, InMemoryRateLimiter};
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class AuthControllerTest extends TestCase final class AuthControllerTest extends TestCase
@ -19,52 +19,61 @@ final class AuthControllerTest extends TestCase
return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]); return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]);
} }
public function testLoginSuccessSetsSession(): void { public function testLoginSuccessSetsSession(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$sess = new ArraySessionStore(); $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(200, $res->status());
$this->assertSame(1, $sess->get('user_id')); $this->assertSame(1, $sess->get('user_id'));
$this->assertSame('operator', $sess->get('role')); $this->assertSame('operator', $sess->get('role'));
} }
public function testBadPasswordIs401(): void { public function testBadPasswordIs401(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000)->status()); $this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status());
} }
public function testLockoutAfterMaxAttempts(): void { public function testLockoutAfterMaxAttempts(): void {
$repo = $this->users(); $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);
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');
// now locked, even correct password is 423 // 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 { public function testExpiredLockResetsAttempts(): void {
$repo = $this->users(); $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 // 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 // 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 // now at now=2000 (after lock window expires at 1900), login with correct password should succeed
$sess = new ArraySessionStore(); $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(200, $res->status());
$this->assertSame(1, $sess->get('user_id')); $this->assertSame(1, $sess->get('user_id'));
} }
public function testUnknownUsernameReturns401GenericBody(): void { public function testUnknownUsernameReturns401GenericBody(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$res = $c->login($this->req('nobody', 'whatever'), new ArraySessionStore(), now: 1000); $res = $c->login($this->req('nobody', 'whatever'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
$this->assertSame(401, $res->status()); $this->assertSame(401, $res->status());
// identical generic body to the wrong-password case (no user-enumeration leak) // 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()); $this->assertSame($wrong->body(), $res->body());
} }
public function testLogoutClearsSession(): void { public function testLogoutClearsSession(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$sess = new ArraySessionStore(); $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')); $this->assertSame(1, $sess->get('user_id'));
$res = $c->logout($sess); $res = $c->logout($sess);
$this->assertSame(200, $res->status()); $this->assertSame(200, $res->status());
$this->assertNull($sess->get('user_id')); // session identity cleared $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());
}
} }