fix(19): CR-04 scope login lockout to username, add TTL auto-expiry + admin-reset unlock

This commit is contained in:
Lucas Berger
2026-06-17 20:20:43 -04:00
parent 6ef8e03f8c
commit b083cb7193
4 changed files with 108 additions and 31 deletions
+34 -2
View File
@@ -5,8 +5,9 @@
* Test 1: valid username+password → 200 { ok:true } + Set-Cookie for local-session
* Test 2: wrong password → 401 { error: 'Invalid credentials' }
* Test 3: unknown username → 401 with SAME body as Test 2 (no enumeration / no field discrimination)
* Test 4: 5 consecutive failures from one IP → 6th returns 429
* Test 4: 5 consecutive failures for one username → 6th returns 429
* Test 5: 10 failures → 423 (lockedOut); a cleared map resets the counter
* Test 5b (CR-04): a 423 lockout auto-expires after LOCKOUT_TTL_MS (no admin reset needed)
* Test 6: POST /api/auth/local/logout clears the local-session cookie (expired Set-Cookie)
* Test 6b: GET /api/auth/local/logout (alias) also clears the local-session cookie
* Test 7 (no-echo): malformed body (missing password) → 400 { error: 'Invalid request' };
@@ -246,8 +247,9 @@ describe('POST /api/auth/local/login', () => {
expect(body.error).toBe('Account locked');
// Clear the map (simulates admin reset) → counter gone → next attempt is 401 again (not locked)
// CR-04: the limiter is keyed on the USERNAME ('alice'), not the IP.
const { loginAttempts } = await import('../../src/routes/localAuth.js');
loginAttempts.delete('10.0.0.2');
loginAttempts.delete('alice');
const resAfterReset = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
@@ -255,6 +257,36 @@ describe('POST /api/auth/local/login', () => {
expect(resAfterReset.status).toBe(401);
});
it('Test 5b (CR-04): a 423 lockout auto-expires after the TTL — no admin reset needed', async () => {
mockCredRow = undefined;
const app = await getApp();
// 10 failures → lockout for username 'bob'
for (let i = 0; i < 10; i++) {
await app.fetch(makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'));
}
// Confirm locked (423)
const resLocked = await app.fetch(
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
);
expect(resLocked.status).toBe(423);
// Simulate the TTL elapsing by back-dating lockedAt well past LOCKOUT_TTL_MS (15 min).
const { loginAttempts } = await import('../../src/routes/localAuth.js');
const entry = loginAttempts.get('bob');
expect(entry?.lockedOut).toBe(true);
if (entry) entry.lockedAt = Date.now() - 16 * 60 * 1000;
// Next attempt: the lockout has expired → handler drops the entry and processes the
// login normally, so a wrong password is a fresh 401 (not a 423). CR-04: self-healing.
const resAfterTtl = await app.fetch(
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
);
expect(resAfterTtl.status).toBe(401);
});
it('Test 7 (no-echo): malformed body (missing password) → 400 { error: "Invalid request" }; body has no echoed value or Zod received field', async () => {
const app = await getApp();
// Body with username but missing password (Zod will reject)