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:
parent
7828f9c419
commit
238b932c86
3 changed files with 38 additions and 21 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue