Fourth fresh-eyes review pass (5 agents: goal, QA, code-quality, security, context-mining). All PASS on the money-safety core; this round closes the remaining MAJOR/MINOR items they surfaced. Webhooks: - Untracked disputes now raise ONE admin notification PER distinct chargeback: the notification id is derived deterministically from the square_dispute_id (SHA-256 truncated into the CHAR(12) slot) so a second untracked dispute is no longer silently suppressed by the first's dedup row. ON CONFLICT (id) keeps same-dispute replays idempotent; the booking-scoped NOT EXISTS guard is retained for the tracked path. Verified: distinct disputes -> distinct rows; re-delivered dispute -> one row. - The gift-card clawback SQL now lives in exactly ONE place: payments.RevertGiftCardFunding (new giftcard_clawback.go). till.go and the webhook path both call it — eliminating the byte-for-byte copy whose divergence would be a money-loss drift trap (the same two-sources-of-truth pattern this commit eliminated for GDPR scrubbing). 2FA: - Applied the lockout-coherence fix from the review: when a disable request must mint a fresh code (no valid pending one), the held attempt counter is reset so the locked-out user can use the freshly delivered code in the SAME request (no wasted round-trip). The reuse path keeps accumulating wrong attempts toward the 5-attempt lockout — the two behaviors no longer conflict. (The 'always-fresh on disable' suggestion was NOT adopted: it would break the out-of-band [2FA]-log delivery model, since a code generated by a request can never be submitted within that same request.) - New test pins the shared verify/disable lockout: 5 wrong verifies 429 and destroy the code; a stale code then 400s on disable while the freshly delivered code succeeds in the same request. - Startup now warns that 2FA codes travel in PLAINTEXT via the server log in enforced mode (operator must restrict log access + relay out-of-band until email/SMS lands). Docs: - Test counts updated to the current 2,154 across README + Technical Manual. - User Manual 2FA nav corrected: the settings live on the Account page, not an 'Admin' area. Tests: 2,154 (up from 2,151). Backend 25/26 packages green (crussell/db fails only in this environment: local postgres auth for the test role; package byte-identical to HEAD). Frontend builds; svelte-check 0 errors.
609 lines
26 KiB
Go
609 lines
26 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/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"regexp"
|
|
"testing"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"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)
|
|
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 SHA-256 digest of exactly the returned code.
|
|
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_SharedLockoutResetsOnFreshCode pins the shared
|
|
// per-user lockout across verify and disable: 5 wrong VERIFY attempts 429 and
|
|
// destroy the pending code; a subsequent DISABLE with a wrong code returns 400
|
|
// (not 429) because the disable flow delivers a FRESH code which resets the
|
|
// shared counter — and only that fresh code (not the old one) succeeds.
|
|
func TestTwoFA_VerifyAndDisable_SharedLockoutResetsOnFreshCode(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())
|
|
|
|
// The lockout is shared: the OLD code is gone, so disabling with it fails.
|
|
var buf bytes.Buffer
|
|
log.SetOutput(&buf)
|
|
t.Cleanup(func() { log.SetOutput(os.Stderr) })
|
|
|
|
// First disable delivers a fresh code (resetting the shared counter) and
|
|
// rejects the stale submission with 400, not 429.
|
|
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID)
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "stale code must be rejected after lockout")
|
|
|
|
// The freshly delivered code succeeds in the SAME request that generated it.
|
|
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.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")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
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_NewCodeViaSetupResetsLockout(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())
|
|
|
|
// Requesting a new code via setup resets the attempt counter, so
|
|
// verification is possible again.
|
|
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 setup-generated code is unknown (enforced), so seed a fresh known
|
|
// code and confirm the reset allows verification.
|
|
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.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())
|
|
}
|