//go:build test package auth // F5.5 regression: a locked account must respond EXACTLY like a generic // invalid-credentials failure — same status, same body — so an attacker can // never distinguish "locked" from "wrong password". The old distinguishable // 429 lockout was an account-existence oracle and a lockout-probing signal. import ( "net/http" "testing" "crussell/testutils" "crussell/testutils/fixtures" "github.com/stretchr/testify/require" ) // TestLogin_LockedAccount_IndistinguishableFromWrongPassword pins the F5.5 // fix: a login attempt against a locked account returns the SAME status and // byte-identical body as a wrong-password attempt against a normal account. // Before the fix the locked account answered 429 "account is temporarily // locked..." — distinguishable from the 401 "invalid credentials" a wrong // password returns, revealing account existence and lockout state. func TestLogin_LockedAccount_IndistinguishableFromWrongPassword(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) handler := http.HandlerFunc(LoginHandler) normalID, err := fixtures.CreateTestUserWithEmail(tx, "normal@test.com", "verified_email") require.NoError(t, err) defer fixtures.DeleteUser(tx, normalID) lockedID, err := fixtures.CreateTestUserWithEmail(tx, "locked@test.com", "verified_email") require.NoError(t, err) defer fixtures.DeleteUser(tx, lockedID) // Lock the account the way the failure path would after 5 wrong passwords. _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = NOW() + INTERVAL '15 minutes' WHERE id = $1`, lockedID) require.NoError(t, err) wWrong := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", LoginRequest{Email: "normal@test.com", Password: "wrong-password"}, ctx) wLocked := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", LoginRequest{Email: "locked@test.com", Password: "wrong-password"}, ctx) require.Equal(t, http.StatusUnauthorized, wWrong.Code, "a wrong password must be 401") require.Equal(t, http.StatusUnauthorized, wLocked.Code, "a locked account must be 401, not 429 (F5.5)") require.Equal(t, wWrong.Body.String(), wLocked.Body.String(), "the locked-account body must be byte-identical to the wrong-password body (no lockout oracle)") } // TestLogin_LockedAccount_CorrectPasswordStillUniform verifies the locked path // stays indistinguishable even when the attacker submits the CORRECT password: // the account is still refused with the generic 401 — the compare result is // deliberately discarded on the locked path. func TestLogin_LockedAccount_CorrectPasswordStillUniform(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) handler := http.HandlerFunc(LoginHandler) // fixtures.CreateTestUserWithEmail hashes "testpassword123". lockedID, err := fixtures.CreateTestUserWithEmail(tx, "locked-correct@test.com", "verified_email") require.NoError(t, err) defer fixtures.DeleteUser(tx, lockedID) _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = NOW() + INTERVAL '15 minutes' WHERE id = $1`, lockedID) require.NoError(t, err) w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", LoginRequest{Email: "locked-correct@test.com", Password: "testpassword123"}, ctx) require.Equal(t, http.StatusUnauthorized, w.Code, "a locked account must refuse even the correct password (F5.5)") require.Equal(t, "invalid credentials\n", w.Body.String()) } // TestLogin_LockoutEscalation pins the escalating lockout (LOW 6 follow-up): the // lock duration grows with sustained failure rounds — 15min at 5-6 failures, // 30min at 7-9, 60min at 10+ — so an attacker who keeps guessing past each // unlock makes the lock LONGER instead of merely sustaining the 15-minute tier. // Every round re-arms the account (locked_until reset to NULL, simulating the // attacker retrying after each unlock); each attempt stays a uniform 401, so // the escalation adds no distinguishable response (F5.5). func TestLogin_LockoutEscalation(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) handler := http.HandlerFunc(LoginHandler) userID, err := fixtures.CreateTestUserWithEmail(tx, "escalate@test.com", "verified_email") require.NoError(t, err) defer fixtures.DeleteUser(tx, userID) // lockSeconds re-arms the account with the given failure count, performs one // wrong-password login, and returns how long the resulting lock lasts. lockSeconds := func(failedAttempts int) float64 { t.Helper() _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = $1, locked_until = NULL WHERE id = $2`, failedAttempts, userID) require.NoError(t, err) w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", LoginRequest{Email: "escalate@test.com", Password: "wrong-password"}, ctx) require.Equal(t, http.StatusUnauthorized, w.Code, "every lockout round stays a uniform 401 (F5.5)") var secs float64 err = tx.QueryRow(ctx, `SELECT EXTRACT(EPOCH FROM (locked_until - NOW())) FROM users WHERE id = $1`, userID).Scan(&secs) require.NoError(t, err) return secs } require.InDelta(t, 900, lockSeconds(4), 5, "5th failure locks for 15 minutes") require.InDelta(t, 900, lockSeconds(5), 5, "6th failure stays on the 15-minute tier") require.InDelta(t, 1800, lockSeconds(6), 5, "7th failure escalates to 30 minutes") require.InDelta(t, 1800, lockSeconds(8), 5, "9th failure stays on the 30-minute tier") require.InDelta(t, 3600, lockSeconds(9), 5, "10th failure escalates to the 60-minute ceiling") }