Three fresh reviews (money/security/dup-mod) cross-validated findings: - MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL - MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks - DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed' - HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert) - LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval) - Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID - Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race - SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence) 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
1433 lines
63 KiB
Go
1433 lines
63 KiB
Go
//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")
|
|
}
|
|
|
|
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.
|
|
mints := strings.Count(buf.String(), "disable 2FA")
|
|
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.
|
|
func TestTwoFASendVerificationCode_ReusesValidPendingCode(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, 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")
|
|
}
|
|
|
|
// 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)
|
|
}
|