//go:build test package user // Tests for the loose-fake 2FA endpoints (GET /api/user/2fa/status, // POST /api/user/2fa/setup|verify|disable). Every test that flips // REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv must stay sequential (no // t.Parallel): os.Getenv is process-global and t.Setenv panics under // t.Parallel. Sequential tests in this package run before the parallel batch, // so the enforced env never leaks into parallel tests. import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "os" "regexp" "strings" "sync" "testing" "time" "crussell/clock" "crussell/db" "crussell/internal/twofa" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "github.com/stretchr/testify/require" ) func twofaEnvEnforced(t *testing.T) { t.Helper() t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "production") } // twoFAMaxAttempts is the number of consecutive failed verify attempts allowed // before the pending code is invalidated and a new one must be requested. const twoFAMaxAttempts = twofa.MaxAttempts // twoFAAttemptWindow bounds how long a per-user attempt counter lives before // resetting, and doubles as the stale-entry eviction horizon for the map. const twoFAAttemptWindow = twofa.AttemptWindow // legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest. func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) } // verifyTwoFACodeHash reports whether reqCode matches a stored pending-code // digest, always in constant time. Delegates to the shared implementation. func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) { return twofa.VerifyHash(reqCode, storedHash) } func twofaEnvUnenforced(t *testing.T) { t.Helper() // Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED // (fail-closed), so an unenforced test must opt in via an explicit dev value. t.Setenv("REQUIRE_2FA", "") t.Setenv("SQUARE_ENVIRONMENT", "mock") } // performUser2FARequest invokes a handler with the authenticated-user context // injected directly (the profile_test.go pattern). An empty userID simulates an // unauthenticated request (no mw.UserIDKey in context). func performUser2FARequest(t *testing.T, handler http.HandlerFunc, ctx context.Context, method, path string, body any, userID string) *httptest.ResponseRecorder { t.Helper() var req *http.Request if body != nil { b, err := json.Marshal(body) require.NoError(t, err) req = httptest.NewRequest(method, path, bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if userID != "" { req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) } else { req = req.WithContext(ctx) } w := httptest.NewRecorder() handler(w, req) return w } // seedPendingTwoFA writes a known verification code's SHA-256 hash plus a fresh // expiry into the user's pending columns, so enforced-mode verify tests don't // depend on reading the logged code. func seedPendingTwoFA(t *testing.T, ctx context.Context, q db.Querier, userID, code string) { t.Helper() _, err := q.Exec(ctx, `UPDATE users SET two_factor_method = 'email', two_factor_pending_code_hash = $2, two_factor_pending_code_expires = $3 WHERE id = $1`, userID, hashTwoFACode(code), clock.Now().Add(twoFAPendingExpiry)) require.NoError(t, err) } // extractCodeFromLog pulls the 6-digit code out of a captured [2FA] log line. func extractCodeFromLog(t *testing.T, logOut string) string { t.Helper() m := regexp.MustCompile(`\[2FA\].*: (\d{6})`).FindStringSubmatch(logOut) if len(m) < 2 { return "" } return m[1] } func TestTwoFAStatus_NotEnabled(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID) require.Equal(t, http.StatusOK, w.Code) var resp TwoFAStatusResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.False(t, resp.Enabled, "fresh user must report 2FA disabled") require.False(t, resp.Required, "unenforced env must report required=false") require.Nil(t, resp.Method) } func TestTwoFAStatus_Required(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID) require.Equal(t, http.StatusOK, w.Code) var resp TwoFAStatusResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.False(t, resp.Enabled) require.True(t, resp.Required, "enforced env must report required=true") } func TestTwoFASetup_InvalidMethod(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "carrier-pigeon"}, userID) require.Equal(t, http.StatusBadRequest, w.Code) } func TestTwoFASetup_Valid_StoresHash(t *testing.T) { twofaEnvUnenforced(t) // Pin the pepper off so the stored hash assertion below is deterministic // regardless of the ambient test environment. t.Setenv("TWO_FACTOR_PEPPER", "") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp struct { Message string `json:"message"` Code string `json:"code"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, "Code sent", resp.Message) require.Len(t, resp.Code, 6, "unenforced env must return the dev-convenience code") // The DB must hold the digest of exactly the returned code. With the pepper // pinned off, that is the plain SHA-256 (the legacy fallback). var pendingHash, method sql.NullString var expires sql.NullTime require.NoError(t, tx.QueryRow(ctx, ` SELECT two_factor_pending_code_hash, two_factor_method, two_factor_pending_code_expires FROM users WHERE id = $1`, userID).Scan(&pendingHash, &method, &expires)) require.True(t, pendingHash.Valid, "setup must write a pending code hash") require.Equal(t, "email", method.String) require.True(t, expires.Valid && expires.Time.After(clock.Now()), "pending code must have a future expiry") sum := sha256.Sum256([]byte(resp.Code)) require.Equal(t, hex.EncodeToString(sum[:]), pendingHash.String, "stored hash must be the SHA-256 of the returned code") } func TestTwoFASetup_AlreadyEnabled_Conflict(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID) require.Equal(t, http.StatusConflict, w.Code) } func TestTwoFAVerify_WrongCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "wrong code must not enable 2FA") } func TestTwoFAVerify_CorrectCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp map[string]bool require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.True(t, resp["enabled"]) var enabled bool var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&enabled, &pendingHash)) require.True(t, enabled, "correct code must enable 2FA") require.False(t, pendingHash.Valid, "pending code must be cleared after verification") } func TestTwoFAVerify_Expired(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Seed the correct code but backdate the expiry so the handler's // pendingExpires.After(clock.Now()) check fails. _, err = tx.Exec(ctx, `UPDATE users SET two_factor_method = 'email', two_factor_pending_code_hash = $2, two_factor_pending_code_expires = NOW() - INTERVAL '1 minute' WHERE id = $1`, userID, hashTwoFACode("123456")) require.NoError(t, err) w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) require.Equal(t, http.StatusBadRequest, w.Code) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "expired code must not enable 2FA") } func TestTwoFAVerify_Unenforced_AnyCodeSucceeds(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Dev bypass: in an unenforced env even an empty code with no pending row // verifies. w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: ""}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.True(t, enabled) } // TestTwoFADisable_CorrectCode verifies that disabling in an enforced env // requires the pending code: the correct code clears the flag, method and // pending fields. func TestTwoFADisable_CorrectCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var enabled bool var method sql.NullString var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&enabled, &method, &pendingHash)) require.False(t, enabled, "disable must clear two_factor_enabled") require.False(t, method.Valid, "disable must clear the method") require.False(t, pendingHash.Valid, "disable must clear the pending code") } // TestTwoFADisable_WrongCode verifies that a wrong code leaves 2FA enabled: // the gate cannot be lifted with the password alone. func TestTwoFADisable_WrongCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.True(t, enabled, "wrong code must not disable 2FA") } // TestTwoFADisable_NoPendingCode_GeneratesFreshCode verifies that disabling // with no valid pending code delivers a fresh one via the [2FA] log channel and // requires it before clearing the flag. func TestTwoFADisable_NoPendingCode_GeneratesFreshCode(t *testing.T) { twofaEnvEnforced(t) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) // No pending code exists: the handler must generate + log a fresh code and // reject the (empty) submission. w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, userID) require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "disable must log the fresh code as the delivery channel") var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.True(t, pendingHash.Valid, "disable must persist a fresh pending code when none existed") var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.True(t, enabled, "fresh code must be verified before 2FA can be disabled") } // TestTwoFADisable_LockoutAfterFiveFailedAttempts verifies that disable shares // the 5-attempt lockout with verify: 4 wrong codes 400, the 5th 429s and // invalidates the pending code. func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") for i := 0; i < 4; i++ { w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1) } w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) require.Contains(t, w.Body.String(), "Too many attempts. Request a new code.") var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.False(t, pendingHash.Valid, "lockout must invalidate the pending code") var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.True(t, enabled, "locked-out user must still have 2FA enabled") } // TestTwoFA_VerifyAndDisable_SharedLockoutPersistsAcrossMints pins the shared // per-user lockout across verify and disable under B11b: 5 wrong VERIFY // attempts 429 and destroy the pending code; the disable flow mints a FRESH // code, but the persistent failed-attempt counter is NOT reset by the mint — a // stale OR the freshly-minted code both stay 429 until the attempt window // elapses. func TestTwoFA_VerifyAndDisable_SharedLockoutPersistsAcrossMints(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") // Burn all 5 attempts on VERIFY: 4 wrong 400, the 5th 429 + code destroyed. for i := 0; i < 4; i++ { w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "verify attempt %d", i+1) } w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) // B11b: the lockout survives the fresh-code mint — the stale submission // stays throttled (429), it is NOT re-budgeted (400). w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, "stale code must stay locked out after verify lockout; body: %s", w.Body.String()) // The freshly minted code is ALSO throttled: the persistent counter caps // total failed attempts per code lifetime, not per code instance. freshCode := extractCodeFromLog(t, buf.String()) require.NotEmpty(t, freshCode, "disable must deliver a fresh code after verify lockout") w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: freshCode}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, "fresh code must not reset the shared lockout; body: %s", w.Body.String()) // Once the attempt window elapses (the code lifetime), the valid pending // code verifies and disables 2FA. st := twoFAAttemptStateFor(userID) st.SetLastActive(clock.Now().Add(-twoFAAttemptWindow - time.Second)) w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: freshCode}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "fresh code must disable 2FA after the shared lockout window elapses") } // TestTwoFADisable_Unenforced_NoCodeRequired verifies the dev bypass: in an // unenforced env disabling works with no code at all. func TestTwoFADisable_Unenforced_NoCodeRequired(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "dev bypass must disable without a code") } func TestTwoFA_Unauthenticated(t *testing.T) { tests := []struct { name string method string path string body any }{ {"status", http.MethodGet, "/api/user/2fa/status", nil}, {"setup", http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}}, {"verify", http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}}, {"disable", http.MethodPost, "/api/user/2fa/disable", nil}, } handlers := map[string]http.HandlerFunc{ "status": GetTwoFAStatusHandler, "setup": SetupTwoFAHandler, "verify": VerifyTwoFAHandler, "disable": DisableTwoFAHandler, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := performUser2FARequest(t, handlers[tt.name], context.Background(), tt.method, tt.path, tt.body, "") require.Equal(t, http.StatusUnauthorized, w.Code) }) } } func TestProfile_Get_IncludesTwoFAState(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) w := performUser2FARequest(t, GetProfileHandler, ctx, http.MethodGet, "/api/user/profile", nil, userID) require.Equal(t, http.StatusOK, w.Code) var profile UserProfile require.NoError(t, json.Unmarshal(w.Body.Bytes(), &profile)) require.True(t, profile.TwoFactorEnabled) require.True(t, profile.TwoFactorRequired, "profile must expose the enforced flag") require.NotNil(t, profile.TwoFactorMethod) require.Equal(t, "email", *profile.TwoFactorMethod) } func TestTwoFA_FailClosedDefaultEnforced(t *testing.T) { // Empty SQUARE_ENVIRONMENT (a misconfigured prod deploy) must default to // ENFORCED, never silently disable the gate. t.Setenv("REQUIRE_2FA", "") t.Setenv("SQUARE_ENVIRONMENT", "") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID) require.Equal(t, http.StatusOK, w.Code) var resp TwoFAStatusResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.True(t, resp.Required, "empty SQUARE_ENVIRONMENT must be treated as enforced (fail-closed)") } func TestTwoFASetup_Enforced_NoCodeInResponse(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) _, hasCode := resp["code"] require.False(t, hasCode, "enforced setup must NOT return the code in the response") } func TestTwoFASetup_CodeAlwaysLoggedAsDeliveryChannel(t *testing.T) { // Capture the standard logger so we can assert on what setup logs. var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) // Enforced (production): the plaintext code MUST be logged — the [2FA] log // line is the only delivery channel until email/SMS lands, and an operator // relays it to the user out-of-band. Without it, enforced-mode 2FA is a // dead-end (every online saved-card charge stays 403). twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "enforced setup must log the plaintext code as the delivery channel") // Unenforced (dev): the plaintext code is also logged for the loose-fake flow. buf.Reset() twofaEnvUnenforced(t) ctx2, tx2 := testutils.SetupTestTx(t) userID2, err := fixtures.CreateTestUser(tx2) require.NoError(t, err) w = performUser2FARequest(t, SetupTwoFAHandler, ctx2, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID2) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "unenforced setup must log the plaintext code") } // TestTwoFASetup_MintThrottled verifies the B11a fix: the setup path applies // the same per-user mint cooldown as the disable flow, so a setup-spam loop // cannot mint fresh codes faster than once per twoFAMintCooldown and keep a // guessing budget alive. func TestTwoFASetup_MintThrottled(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // First setup is allowed and mints a code. w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) // A second setup inside the cooldown is throttled with 429. w = performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) require.Contains(t, w.Body.String(), "Wait before requesting a new code.") // Elapsing the cooldown allows a fresh mint (the test cannot wait a minute). st := twoFAAttemptStateFor(userID) st.LastMintAt = clock.Now().Add(-twoFAMintCooldown - time.Second) w = performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } func TestTwoFAVerify_LockoutAfterFiveFailedAttempts(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") // Attempts 1-4: plain 400. for i := 0; i < 4; i++ { w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1) } // Attempt 5: lockout — 429 and the pending code is invalidated. w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) require.Contains(t, w.Body.String(), "Too many attempts. Request a new code.") var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.False(t, pendingHash.Valid, "lockout must invalidate the pending code") var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "locked-out user must not be enabled") // Attempt 6: still 429 (even with the correct code) until a new code is // requested via setup. w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, "post-lockout attempts must keep returning 429") } func TestTwoFAVerify_NewCodeViaSetup_DoesNotResetLockout(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") // Reach lockout: 4 plain 400s, then the 5th failure locks out. for i := 0; i < 4; i++ { w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1) } w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) // B11b: a fresh code via setup does NOT reset the persistent failed-attempt // counter (it resets only on a SUCCESSFUL verify). The mint is allowed // (first mint, no cooldown) but the lockout survives it. w = performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) // The locked-out user stays locked out even with a fresh code pending. seedPendingTwoFA(t, ctx, tx, userID, "654321") w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "654321"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, "counter must NOT reset on re-mint; body: %s", w.Body.String()) // Only a SUCCESSFUL verify resets the counter. Elapse the attempt window // (the code lifetime) so the stale counter is dropped, then a fresh verify // succeeds and clears it. st := twoFAAttemptStateFor(userID) st.SetLastActive(clock.Now().Add(-twoFAAttemptWindow - time.Second)) seedPendingTwoFA(t, ctx, tx, userID, "111111") w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "111111"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } func TestTwoFAVerify_WrongCodesAnyLengthRejected(t *testing.T) { // Exercises the constant-time compare path: wrong codes of any length and // shape fail identically (400) without enabling, while the correct code // still succeeds — no length-based early exit leaks match information. twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") for _, code := range []string{"12345", "1234567", "abcdef", ""} { w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: code}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "wrong code %q must be rejected", code) } var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled) // Four wrong attempts were consumed above; one more would lock out. Use a // fresh user to prove the correct code still verifies. userID2, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID2, "123456") w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID2) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } // ============================================================================= // Pepper hashing (finding c) // ============================================================================= // TestTwoFAPepper_HashUsesHMAC verifies that with TWO_FACTOR_PEPPER set the // stored digest is HMAC-SHA256 keyed by the pepper, NOT the legacy unsalted // SHA-256 — so a leaked digest cannot be brute-forced offline. func TestTwoFAPepper_HashUsesHMAC(t *testing.T) { t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret") const code = "123456" got := hashTwoFACode(code) mac := hmac.New(sha256.New, []byte("test-pepper-secret")) mac.Write([]byte(code)) want := hex.EncodeToString(mac.Sum(nil)) require.Equal(t, want, got, "stored hash must be HMAC-SHA256 keyed by TWO_FACTOR_PEPPER") require.NotEqual(t, legacyHashTwoFACode(code), got, "pepper'd hash must differ from the legacy plain SHA-256") } // TestTwoFAPepper_UnsetFallback_PlainSHA256 verifies the graceful no-pepper // fallback keeps the legacy unsalted SHA-256 digest when TWO_FACTOR_PEPPER is // unset. func TestTwoFAPepper_UnsetFallback_PlainSHA256(t *testing.T) { t.Setenv("TWO_FACTOR_PEPPER", "") const code = "654321" got := hashTwoFACode(code) sum := sha256.Sum256([]byte(code)) require.Equal(t, hex.EncodeToString(sum[:]), got, "unset pepper must fall back to legacy plain SHA-256") require.Equal(t, legacyHashTwoFACode(code), got) } // TestTwoFAPepper_LegacyHashDetected verifies verifyTwoFACodeHash accepts both // the pepper'd and the legacy plain forms (the transition window) and flags // legacy rows for upgrade. func TestTwoFAPepper_LegacyHashDetected(t *testing.T) { t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret") const code = "123456" match, legacy := verifyTwoFACodeHash(code, hashTwoFACode(code)) require.True(t, match) require.False(t, legacy, "pepper'd stored hash must not be flagged for upgrade") match, legacy = verifyTwoFACodeHash(code, legacyHashTwoFACode(code)) require.True(t, match) require.True(t, legacy, "legacy stored hash must verify and flag the upgrade") match, legacy = verifyTwoFACodeHash("999999", legacyHashTwoFACode(code)) require.False(t, match) require.False(t, legacy) } // TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify verifies that a legacy // pre-pepper row still verifies during the migration window AND that the stored // hash is upgraded to the pepper'd form on the next successful verify (the // plain digest is retired). func TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify(t *testing.T) { t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Seed a legacy row exactly as the pre-pepper code wrote it: plain SHA-256. _, err = tx.Exec(ctx, `UPDATE users SET two_factor_method = 'email', two_factor_pending_code_hash = $2, two_factor_pending_code_expires = $3 WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry)) require.NoError(t, err) // checkTwoFACode (the shared verify path) must accept the legacy hash. st := &twofa.AttemptState{} st.SetLastActive(clock.Now()) req := httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(ctx) result, err := checkTwoFACode(req, userID, st, "123456") require.NoError(t, err) require.Equal(t, twoFACodeOK, result) // The stored hash must now be the pepper'd form. var stored sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&stored)) require.True(t, stored.Valid, "checkTwoFACode alone must not clear the pending hash") require.Equal(t, hashTwoFACode("123456"), stored.String, "legacy hash must be upgraded to the pepper'd form on successful verify") } // TestTwoFAVerify_LegacyHash_StillVerifies pins the end-to-end migration // window: an enforced env with the pepper set must still accept a user whose // pending code was hashed the old (pre-pepper) way. func TestTwoFAVerify_LegacyHash_StillVerifies(t *testing.T) { twofaEnvEnforced(t) t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_method = 'email', two_factor_pending_code_hash = $2, two_factor_pending_code_expires = $3 WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry)) require.NoError(t, err) w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.True(t, enabled) } // TestTwoFAVerify_LegacyHash_WrongCodeRejected verifies the legacy path still // enforces the correct code: a wrong code against a legacy-hashed row is // rejected and 2FA stays off. func TestTwoFAVerify_LegacyHash_WrongCodeRejected(t *testing.T) { twofaEnvEnforced(t) t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_method = 'email', two_factor_pending_code_hash = $2, two_factor_pending_code_expires = $3 WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry)) require.NoError(t, err) w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled) } // ============================================================================= // Disable-flow mint throttle (finding d) // ============================================================================= // TestTwoFADisable_MintThrottled_BoundsGuessing verifies the unlimited-guess // loop is closed: a password-only attacker who burns the 5-attempt budget on a // freshly minted code cannot mint ANOTHER fresh code (which would reset the // counter) inside the per-user mint cooldown. Exactly one fresh code is minted // across the whole loop and the throttled request returns 429. func TestTwoFADisable_MintThrottled_BoundsGuessing(t *testing.T) { twofaEnvEnforced(t) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) // Request 1 mints a fresh code (the first mint is allowed) and rejects the // wrong submission; requests 2-5 reuse that code, reaching the lockout. for i := 0; i < 4; i++ { w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1) } w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) // Request 6 is inside the cooldown: no fresh code may be minted, so the loop // stops with 429 instead of minting an unlimited series of fresh codes. w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) require.Contains(t, w.Body.String(), "Wait before requesting a new code.") // Exactly ONE fresh code was minted across all six requests — the loop can // no longer reset the attempt budget. Count the fresh-delivery marker // ("[2FA] code delivery requested") rather than the purpose substring: // finding 2b added a "[2FA] code reused" log line that also names the // purpose, so a raw purpose count would over-count. mints := strings.Count(buf.String(), "[2FA] code delivery requested") require.Equal(t, 1, mints, "expected exactly 1 fresh-code mint; log:\n%s", buf.String()) } // TestTwoFADisable_MintThrottle_ExpiresAllowsRecovery verifies the documented // residual is not a permanent lockout: once the mint cooldown AND the attempt // window (the code lifetime) elapse, a legitimate code-lost user can mint and // verify a fresh code again. Under B11b the failed-attempt counter persists // across mints, so elapsing only the mint cooldown is NOT enough — the user // must also wait out the 10-minute attempt window. func TestTwoFADisable_MintThrottle_ExpiresAllowsRecovery(t *testing.T) { twofaEnvEnforced(t) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) // Burn the budget: 4 wrong 400s, the 5th locks out (429). for i := 0; i < 4; i++ { w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1) } w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, "mint must be throttled inside the cooldown") // Elapsing ONLY the mint cooldown still leaves the user locked out: the // persistent failed-attempt counter (B11b) survives the fresh mint (the // mint itself writes the new [2FA] log line). st := twoFAAttemptStateFor(userID) st.LastMintAt = clock.Now().Add(-twoFAMintCooldown - time.Second) buf.Reset() w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, "persistent counter must keep the user locked until the attempt window elapses") require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "mint cooldown elapse must allow a fresh mint") // Elapse the attempt window too (simulate a real user waiting it out): the // valid pending code is reused and the lockout lapses — the wrong code is // rejected with 400 (a fresh 5-attempt budget), not 429. st.SetLastActive(clock.Now().Add(-twoFAAttemptWindow - time.Second)) w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) // After the successful disable (2FA now off), the counter is cleared — a // subsequent re-enable + verify starts from a fresh budget. _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } // ============================================================================= // Attempt-map eviction (finding e) // ============================================================================= // TestTwoFAAttemptMap_InLockoutRecordNotEvicted verifies the eviction fix: a // record still inside its lockout window is NEVER evicted by LRU pressure — a // hostile flood of new keys cannot reset the victim's attempt counter. Only an // idle/expired record is dropped to make room. func TestTwoFAAttemptMap_InLockoutRecordNotEvicted(t *testing.T) { twofa.MapMu.Lock() origMap := twofa.Map origCap := twofa.MaxTrackedAttempts twofa.Map = make(map[string]*twoFAAttemptState) twofa.MaxTrackedAttempts = 4 twofa.MapMu.Unlock() t.Cleanup(func() { twofa.MapMu.Lock() twofa.Map = origMap twofa.MaxTrackedAttempts = origCap twofa.MapMu.Unlock() }) now := clock.Now() for _, id := range []string{"idle_a", "idle_b", "idle_c"} { st := &twofa.AttemptState{} st.SetLastActive(now.Add(-time.Minute)) twofa.Map[id] = st } victim := &twofa.AttemptState{} victim.SetLastActive(now.Add(-time.Second)) victim.Count.Store(5) twofa.Map["victim"] = victim // A new user hits the cap: the eviction must drop an idle record, never the // in-lockout victim. st := twoFAAttemptStateFor("new_user") require.NotNil(t, st) if _, ok := twofa.Map["victim"]; !ok { t.Error("in-lockout record must never be evicted by LRU pressure") } if got := twofa.Map["victim"].Count.Load(); got != 5 { t.Errorf("victim attempt count must survive eviction pressure, got %d", got) } if len(twofa.Map) > 4 { t.Errorf("map must stay within the cap, got %d entries", len(twofa.Map)) } if _, ok := twofa.Map["new_user"]; !ok { t.Error("new user must be tracked in the map") } } // TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient verifies the pathological // case: when every entry is a locked-out in-window record (a flood), the map // does NOT evict one and does NOT grow past the cap — the new user gets a // transient, untracked state for this request instead. func TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient(t *testing.T) { twofa.MapMu.Lock() origMap := twofa.Map origCap := twofa.MaxTrackedAttempts twofa.Map = make(map[string]*twoFAAttemptState) twofa.MaxTrackedAttempts = 3 twofa.MapMu.Unlock() t.Cleanup(func() { twofa.MapMu.Lock() twofa.Map = origMap twofa.MaxTrackedAttempts = origCap twofa.MapMu.Unlock() }) now := clock.Now() for i := 0; i < 3; i++ { st := &twofa.AttemptState{} st.SetLastActive(now.Add(-time.Second)) st.Count.Store(5) twofa.Map[fmt.Sprintf("locked_%d", i)] = st } st := twoFAAttemptStateFor("new_user") require.NotNil(t, st) if _, ok := twofa.Map["new_user"]; ok { t.Error("expected the transient state NOT to be stored when the map is full of in-lockout records") } if len(twofa.Map) != 3 { t.Errorf("expected all 3 locked-out records to survive, got %d", len(twofa.Map)) } } // ============================================================================= // Attempt-map concurrency (finding-f — lastAt data race) // ============================================================================= // TestTwoFAAttemptMap_ConcurrentVerifyAndEviction is a -race smoke test for the // attempt-map data race: checkTwoFACode's st.mu-guarded writes to count and // lastAt run concurrently with twoFAAttemptStateFor's mapMu-only eviction scan // reading them. lastAt is an atomic.Int64 (nanoseconds since the epoch), so the // scan and lockedOut read it without st.mu — no mutex inversion (mapMu→st.mu is // forbidden) and no torn 8-byte timestamp. Asserts every concurrent call // completes (no deadlock), pre-pinned locked-out records survive the eviction // pressure, and the map never grows past the cap. func TestTwoFAAttemptMap_ConcurrentVerifyAndEviction(t *testing.T) { twofa.MapMu.Lock() origMap := twofa.Map origCap := twofa.MaxTrackedAttempts twofa.Map = make(map[string]*twoFAAttemptState) twofa.MaxTrackedAttempts = 128 twofa.MapMu.Unlock() t.Cleanup(func() { twofa.MapMu.Lock() twofa.Map = origMap twofa.MaxTrackedAttempts = origCap twofa.MapMu.Unlock() }) const verifyWorkers = 6 const evictWorkers = 4 const iters = 25 // Pre-pin locked-out victims so we can assert afterwards that in-window // lockout records are never evicted under concurrent pressure. now := clock.Now() victims := make(map[string]*twoFAAttemptState, verifyWorkers) twofa.MapMu.Lock() for i := 0; i < verifyWorkers; i++ { st := &twofa.AttemptState{} st.SetLastActive(now.Add(-time.Second)) st.Count.Store(twoFAMaxAttempts) id := fmt.Sprintf("victim_%d", i) twofa.Map[id] = st victims[id] = st } twofa.MapMu.Unlock() var wg sync.WaitGroup // Verifiers mirror checkTwoFACode's critical section on shared states: take // st.mu, reset an expired window, bump the counter, stamp lastAt, and read // lockedOut — overlapping the eviction scan's lock-free atomic reads. for w := 0; w < verifyWorkers; w++ { wg.Add(1) go func(w int) { defer wg.Done() for iter := 0; iter < iters; iter++ { st := twoFAAttemptStateFor(fmt.Sprintf("verify_%d_%d", w, iter)) st.Mu.Lock() if now := clock.Now(); now.Sub(st.LastActive()) > twoFAAttemptWindow { st.Count.Store(0) st.SetLastActive(now) } _ = st.LockedOut(clock.Now()) st.Count.Add(1) st.SetLastActive(clock.Now()) st.Mu.Unlock() } }(w) } // Evictors drive twoFAAttemptStateFor's cap-driven eviction scan, which // reads count + lastAt WITHOUT st.mu — the access pattern under test. for w := 0; w < evictWorkers; w++ { wg.Add(1) go func(w int) { defer wg.Done() for iter := 0; iter < 2000; iter++ { _ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter)) } }(w) } wg.Wait() twofa.MapMu.Lock() defer twofa.MapMu.Unlock() for id, st := range victims { if _, ok := twofa.Map[id]; !ok { t.Errorf("in-window lockout record %s was evicted under concurrent pressure", id) } if !st.LockedOut(clock.Now()) { t.Errorf("victim %s must still report locked out", id) } } if len(twofa.Map) > twofa.MaxTrackedAttempts { t.Errorf("map grew past the cap: %d > %d", len(twofa.Map), twofa.MaxTrackedAttempts) } } // TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock runs the REAL verify path // concurrently: each goroutine mints its own transaction and user (pgx.Tx is // not concurrency-safe, so per-goroutine tx avoids sharing one), burns the // 5-attempt budget to lockout, and asserts lockedOut afterwards — while other // goroutines hammer the map eviction scan through twoFAAttemptStateFor. The // test completes only if no goroutine deadlocks on mapMu/st.mu. func TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock(t *testing.T) { twofa.MapMu.Lock() origMap := twofa.Map origCap := twofa.MaxTrackedAttempts twofa.Map = make(map[string]*twoFAAttemptState) twofa.MaxTrackedAttempts = 64 twofa.MapMu.Unlock() t.Cleanup(func() { twofa.MapMu.Lock() twofa.Map = origMap twofa.MaxTrackedAttempts = origCap twofa.MapMu.Unlock() }) const workers = 6 const iters = 15 var wg sync.WaitGroup errCh := make(chan error, workers) for w := 0; w < workers; w++ { wg.Add(1) go func(w int) { defer wg.Done() ctx := context.Background() tx, err := db.Conn.Pool().Begin(ctx) if err != nil { errCh <- fmt.Errorf("worker %d begin: %w", w, err) return } defer tx.Rollback(context.Background()) tctx := db.ContextWithTx(ctx, tx) for iter := 0; iter < iters; iter++ { userID, err := fixtures.CreateTestUser(tx) if err != nil { errCh <- fmt.Errorf("worker %d iter %d create user: %w", w, iter, err) return } seedPendingTwoFA(t, tctx, tx, userID, "123456") st := twoFAAttemptStateFor(userID) for attempt := 1; attempt <= twoFAMaxAttempts; attempt++ { st.Mu.Lock() res, err := checkTwoFACode(httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(tctx), userID, st, "999999") st.Mu.Unlock() if err != nil { errCh <- fmt.Errorf("worker %d iter %d check: %w", w, iter, err) return } want := twoFACodeIncorrect if attempt == twoFAMaxAttempts { want = twoFACodeLockedOut } if res != want { errCh <- fmt.Errorf("worker %d iter %d attempt %d: got %v, want %v", w, iter, attempt, res, want) return } } if !st.LockedOut(clock.Now()) { errCh <- fmt.Errorf("worker %d iter %d: must be locked out after %d wrong codes", w, iter, twoFAMaxAttempts) return } } }(w) } // Concurrent map pressure: twoFAAttemptStateFor reads count + lastAt under // mapMu only, racing the workers' st.mu-guarded writes (the old data race). for w := 0; w < 4; w++ { wg.Add(1) go func(w int) { defer wg.Done() for iter := 0; iter < 1000; iter++ { _ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter)) } }(w) } wg.Wait() close(errCh) for err := range errCh { t.Error(err) } } // ============================================================================= // Disable-flow code mint endpoint (POST /api/user/2fa/disable/code) // ============================================================================= // TestTwoFADisableCode_MintsFreshCode verifies that with no pending code the // endpoint mints a fresh one and persists it (hash + unexpired expiry), so the // frontend's disable code-entry step has a delivered code to verify against. func TestTwoFADisableCode_MintsFreshCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var pendingHash sql.NullString var expires sql.NullTime require.NoError(t, tx.QueryRow(ctx, ` SELECT two_factor_pending_code_hash, two_factor_pending_code_expires FROM users WHERE id = $1`, userID).Scan(&pendingHash, &expires)) require.True(t, pendingHash.Valid, "disable/code must mint a pending code hash") require.True(t, expires.Valid && expires.Time.After(clock.Now()), "minted code must have a future expiry") } // TestTwoFADisableCode_ReusesValidPendingCode verifies that a valid unexpired // pending code is reused by EnsurePendingTwoFACode (the stored hash is // unchanged) instead of minting a fresh one. func TestTwoFADisableCode_ReusesValidPendingCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.True(t, pendingHash.Valid) require.Equal(t, hashTwoFACode("123456"), pendingHash.String, "existing valid pending code must be reused, not re-minted") } // TestTwoFADisableCode_MintThrottled verifies the per-user mint cooldown: a // second code request inside twoFAMintCooldown returns 429. The pending code is // dropped first (as a lockout does) because a still-valid code is reused by // EnsurePendingTwoFACode, which short-circuits the cooldown check. func TestTwoFADisableCode_MintThrottled(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // First request mints a fresh code and stamps the per-user cooldown. w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) // Drop the pending code so the next request cannot reuse it and must hit // the cooldown check instead. _, err = tx.Exec(ctx, `UPDATE users SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL WHERE id = $1`, userID) require.NoError(t, err) w = performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) require.Contains(t, w.Body.String(), "Too many attempts. Wait before requesting a new code.") } // TestTwoFADisableCode_Unauthorized verifies that an unauthenticated request is // rejected with 401 before any minting happens. func TestTwoFADisableCode_Unauthorized(t *testing.T) { w := performUser2FARequest(t, SendDisableCodeHandler, context.Background(), http.MethodPost, "/api/user/2fa/disable/code", nil, "") require.Equal(t, http.StatusUnauthorized, w.Code) } // TestTwoFADisableCode_UnenforcedStillMints verifies the endpoint mints a // pending code in unenforced (dev) environments too — the disable handler's dev // bypass needs no code, but the endpoint still runs unconditionally so the // code-entry step is exercisable locally. func TestTwoFADisableCode_UnenforcedStillMints(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.True(t, pendingHash.Valid, "unenforced env must still mint a pending code") } // TestTwoFASendVerificationCode_Enabled_MintsFresh verifies POST // /api/user/2fa/code: an ENABLED user with no pending code gets a fresh code // minted + delivered ([2FA] log labelled "saved-card charge"), with only the // hash + a future expiry persisted and no code in the enforced response. func TestTwoFASendVerificationCode_Enabled_MintsFresh(t *testing.T) { twofaEnvEnforced(t) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, "Code sent", resp["message"]) _, hasCode := resp["code"] require.False(t, hasCode, "enforced env must NOT return the code in the response") require.Equal(t, float64(600), resp["remaining_seconds"], "a fresh mint must report the full 10-minute lifetime (LOW 5)") var pendingHash sql.NullString var expires sql.NullTime require.NoError(t, tx.QueryRow(ctx, ` SELECT two_factor_pending_code_hash, two_factor_pending_code_expires FROM users WHERE id = $1`, userID).Scan(&pendingHash, &expires)) require.True(t, pendingHash.Valid, "endpoint must mint a pending code hash") require.True(t, expires.Valid && expires.Time.After(clock.Now()), "minted code must have a future expiry") require.Contains(t, buf.String(), "saved-card charge", "delivery log must label the charge purpose") require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "endpoint must log the code as the delivery channel") } // TestTwoFASendVerificationCode_ReusesValidPendingCode verifies that a valid // unexpired pending code is reused (the stored hash is unchanged) instead of a // fresh mint, so a mid-flow charge retry is not throttled — and (finding 2b) // that the reuse leaves an auditable [2FA] log line instead of being silent. func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) { twofaEnvEnforced(t) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, "Code sent", resp["message"]) // LOW 5: the reused pending code's remaining lifetime is reported so the // client knows a NEW code was NOT minted and can count down the existing one. rem, ok := resp["remaining_seconds"].(float64) require.True(t, ok, "response must include remaining_seconds") require.Greater(t, rem, 0.0, "reused code must still have lifetime remaining") require.LessOrEqual(t, rem, 600.0, "reused code lifetime must not exceed the 10-minute window") var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.True(t, pendingHash.Valid) require.Equal(t, hashTwoFACode("123456"), pendingHash.String, "existing valid pending code must be reused, not re-minted") // Finding 2b: reuse must be visible in the [2FA] log stream (no fresh code // is delivered, so this is the only audit trace of the reuse). require.Contains(t, buf.String(), "[2FA] code reused", "a reused code must leave a [2FA] log line") require.NotContains(t, buf.String(), "[2FA] code:", "a reused code must NOT be logged as a fresh delivery") } // TestTwoFASendVerificationCode_NotEnabled_409 verifies that a user who has NOT // enabled 2FA is refused with 409 (the endpoint exists only to re-challenge an // enabled user's saved-card charge; setup covers the not-enabled path). func TestTwoFASendVerificationCode_NotEnabled_409(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID) require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) } // TestTwoFASendVerificationCode_MintThrottled verifies the per-user mint // cooldown applies: a second code request inside twoFAMintCooldown returns 429. // The pending code is dropped first (as a lockout does) because a still-valid // code is reused by EnsurePendingTwoFACode, which short-circuits the cooldown. func TestTwoFASendVerificationCode_MintThrottled(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) // Drop the pending code so the next request cannot reuse it and must hit // the cooldown check instead. _, err = tx.Exec(ctx, `UPDATE users SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL WHERE id = $1`, userID) require.NoError(t, err) w = performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) require.Contains(t, w.Body.String(), "Too many attempts. Wait before requesting a new code.") } // TestTwoFASendVerificationCode_Unenforced_ReturnsCode verifies the dev // convenience: in an unenforced env the endpoint mints and returns the code in // the response (matching setup), and the DB holds the digest of exactly it. func TestTwoFASendVerificationCode_Unenforced_ReturnsCode(t *testing.T) { twofaEnvUnenforced(t) t.Setenv("TWO_FACTOR_PEPPER", "") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp struct { Message string `json:"message"` Code string `json:"code"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, "Code sent", resp.Message) require.Len(t, resp.Code, 6, "unenforced env must return the dev-convenience code") var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.True(t, pendingHash.Valid) sum := sha256.Sum256([]byte(resp.Code)) require.Equal(t, hex.EncodeToString(sum[:]), pendingHash.String, "stored hash must be the SHA-256 of the returned code") } // TestTwoFASendVerificationCode_Unauthorized verifies that an unauthenticated // request is rejected with 401 before any minting happens. func TestTwoFASendVerificationCode_Unauthorized(t *testing.T) { w := performUser2FARequest(t, SendVerificationCodeHandler, context.Background(), http.MethodPost, "/api/user/2fa/code", nil, "") require.Equal(t, http.StatusUnauthorized, w.Code) } // TestTwoFACodeVerifyForUser exercises the exported helper that backs the // B6/B10 payments gate (the saved-card charge must present a real 2FA // challenge): correct code → nil, wrong code → twofa.ErrIncorrect, exhausting // the budget → twofa.ErrLockedOut, and no pending code → twofa.ErrMissingOrExpired. func TestTwoFACodeVerifyForUser(t *testing.T) { twofaEnvEnforced(t) t.Setenv("TWO_FACTOR_PEPPER", "") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") require.NoError(t, VerifyTwoFACodeForUser(ctx, userID, "123456"), "correct code must verify") // A correct verify clears the counter, so a second correct code also works. require.NoError(t, VerifyTwoFACodeForUser(ctx, userID, "123456")) // Wrong code → ErrIncorrect. err = VerifyTwoFACodeForUser(ctx, userID, "999999") require.ErrorIs(t, err, twofa.ErrIncorrect) // Four more wrong codes reach the 5-attempt cap → ErrLockedOut. for i := 0; i < 4; i++ { _ = VerifyTwoFACodeForUser(ctx, userID, "999999") } err = VerifyTwoFACodeForUser(ctx, userID, "999999") require.ErrorIs(t, err, twofa.ErrLockedOut, "after 5 consecutive failures the helper must lock out") // A user with no pending code → ErrMissingOrExpired. userID2, err := fixtures.CreateTestUser(tx) require.NoError(t, err) err = VerifyTwoFACodeForUser(ctx, userID2, "123456") require.ErrorIs(t, err, twofa.ErrMissingOrExpired) }