From c437f408bbf6f3733ed15d75b986d57b9b0b389b Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:49:59 -0400 Subject: [PATCH] feat(19-03): implement POST /api/auth/local/login (rate-limit + lockout) + logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rate-limit: per-IP in-memory Map; 5 failures → 429, 10 → 423 (lockedOut) - Counter increments even on 429 so brute-force accumulates toward lockout - Timing-safe: DUMMY_HASH ensures verifyPassword runs for unknown usernames (T-19-12) - noEchoHook: Zod errors never echo submitted values (T-19-14) - Same 401 body for wrong-password and unknown-username (no enumeration) - POST+GET /local/logout clear the local-session cookie --- apps/api/src/routes/localAuth.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/localAuth.ts b/apps/api/src/routes/localAuth.ts index f3b3663..5069c99 100644 --- a/apps/api/src/routes/localAuth.ts +++ b/apps/api/src/routes/localAuth.ts @@ -87,12 +87,21 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook) const attempt = loginAttempts.get(ip); // 423: account locked (>= LOCKOUT_FAILURES total failures, admin must reset) + // Check lockedOut FIRST — lockout takes precedence over rate window. if (attempt?.lockedOut) { return c.json({ error: 'Account locked' }, 423); } - // 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window + // 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window. + // Increment the counter even on 429 so continued brute-force accumulates toward lockout. if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) { + attempt.count += 1; + attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; + attempt.lockedOut = attempt.count >= LOCKOUT_FAILURES; + loginAttempts.set(ip, attempt); + if (attempt.lockedOut) { + return c.json({ error: 'Account locked' }, 423); + } return c.json({ error: 'Too many attempts' }, 429); }