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.
112 lines
5.4 KiB
Go
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")
|
|
}
|