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);
$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));

View file

@ -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);

View file

@ -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());
}
}