Files
Crussell/backend/handlers/auth/lockout_test.go
T
popertots 1179293777 fix: 2FA single-use consume is DB-atomic; login lockout escalates 15/30/60m with uniform 401
F5.5 brute-force hardening:
- internal/twofa.Check consume is now a conditional UPDATE (WHERE id AND
  two_factor_pending_code_hash) reporting rows affected: two concurrent
  verifications of the same code on different instances both match the digest,
  but only the first conditional UPDATE can affect a row — the loser sees 0
  rows and fails MissingOrExpired, so one code authorizes exactly ONE operation
  across instances (the per-user mutex only serialized within one process).
- /login lockout is now indistinguishable from a wrong password: a locked
  account returns the same uniform 401 'invalid credentials' and burns the same
  constant-time bcrypt compare (via the shared semaphore), removing the
  account-existence oracle and lockout-probing signal of the old 429.
- Lockout tiers escalate 15m (5+) / 30m (7+) / 60m (10+): an attacker who keeps
  guessing past each unlock makes the lock LONGER, raising the repeat-DoS
  effort while the response stays uniform.
2026-08-22 00:34:50 +01:00

112 lines
5.4 KiB
Go

//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")
}