fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX
Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa: - B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows - M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record - max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed - 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter) - Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family - Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests - Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
This commit is contained in:
@@ -213,7 +213,7 @@ func TestPasswordChange_RevokesTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
// The consumed refresh token no longer verifies.
|
||||
if _, _, err := auth.VerifyRefreshToken(ctx, refreshToken); err == nil {
|
||||
if _, _, _, err := auth.VerifyRefreshToken(ctx, refreshToken); err == nil {
|
||||
t.Error("refresh token must be invalid after a password change (B9)")
|
||||
}
|
||||
}
|
||||
|
||||
+103
-14
@@ -322,9 +322,11 @@ const (
|
||||
// DisableTwoFAHandler and VerifyTwoFACodeForUser. The caller must hold st.Mu
|
||||
// (from twoFAAttemptStateFor) so concurrent attempts from the same user cannot
|
||||
// race the limit check. Delegates to the shared implementation in
|
||||
// crussell/internal/twofa.
|
||||
// crussell/internal/twofa with consume=false: the interactive setup/disable
|
||||
// flows clear the pending code themselves on success (enableTwoFA /
|
||||
// disableTwoFA), so the code must stay valid through the whole handshake here.
|
||||
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
|
||||
res, err := twofa.Check(r.Context(), userID, st, reqCode)
|
||||
res, err := twofa.Check(r.Context(), userID, st, reqCode, false)
|
||||
return twoFACodeCheckResult(res), err
|
||||
}
|
||||
|
||||
@@ -345,7 +347,7 @@ func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error {
|
||||
st.Mu.Lock()
|
||||
defer st.Mu.Unlock()
|
||||
|
||||
result, err := twofa.Check(ctx, userID, st, code)
|
||||
result, err := twofa.Check(ctx, userID, st, code, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -506,7 +508,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
st.Mu.Lock()
|
||||
defer st.Mu.Unlock()
|
||||
|
||||
if err := ensurePendingTwoFACode(r, userID, st); err != nil {
|
||||
if _, err := ensurePendingTwoFACode(r, userID, st, "disable 2FA"); err != nil {
|
||||
if errors.Is(err, errTwoFAMintThrottled) {
|
||||
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
@@ -525,6 +527,84 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// POST /api/user/2fa/code
|
||||
// Lets an ENABLED user request a fresh verification code for a saved-card
|
||||
// charge (the B6/B10 gate). This closes the enforced-deployment dead-end where
|
||||
// 2FA setup clears the pending code and SetupTwoFAHandler refuses already
|
||||
// enabled users (409): without it there is no way to mint a code for a
|
||||
// saved-card charge, so every charge returned 400 "Verification code expired —
|
||||
// request a new one" with no way to get a new one.
|
||||
//
|
||||
// The mint machinery is shared with the disable flow: ensurePendingTwoFACode
|
||||
// reuses a still-valid pending code when one exists and otherwise mints +
|
||||
// delivers a fresh one via the same build-dependent channel as setup
|
||||
// (deliverTwoFACode — [2FA] log in dev/test; pepper- and delivery-channel
|
||||
// gated in production). Fresh-code mints are throttled per-user
|
||||
// (twoFAMintCooldown) and never reset the failed-attempt counter (B11b).
|
||||
//
|
||||
// Contract: 200 {"message":"Code sent"} (+ a dev-only "code" field when 2FA is
|
||||
// unenforced, matching setup); 409 when the user has not enabled 2FA; 429 on
|
||||
// the mint cooldown; 503 when no delivery channel is configured (production
|
||||
// without TWO_FACTOR_ALLOW_LOG_DELIVERY=true); 500 on DB failure. The route is
|
||||
// mounted with RequireAuth + RequireNonGuest + the shared per-user 2FA limiter
|
||||
// (plus the group's per-IP limiter), so an enabled user cannot hammer code
|
||||
// requests faster than the surface budget.
|
||||
func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var enabled bool
|
||||
err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
||||
if err != nil {
|
||||
log.Printf("failed to check 2FA state for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
http.Error(w, "Two-factor authentication is not enabled", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// The per-user mutex serializes the mint with the charge gate's verify
|
||||
// critical section so concurrent requests from the same user cannot race
|
||||
// the cooldown or lockout counters.
|
||||
st := twoFAAttemptStateFor(userID)
|
||||
st.Mu.Lock()
|
||||
defer st.Mu.Unlock()
|
||||
|
||||
code, err := ensurePendingTwoFACode(r, userID, st, "saved-card charge")
|
||||
if err != nil {
|
||||
if errors.Is(err, errTwoFAMintThrottled) {
|
||||
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errTwoFADeliveryUnavailable) {
|
||||
// Production with no delivery channel: no fresh code can be minted,
|
||||
// so the saved-card charge cannot be re-challenged. Surface the
|
||||
// actionable setup error instead of a silent 500.
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
log.Printf("failed to prepare 2FA code for saved-card charge for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{"message": "Code sent"}
|
||||
if !twoFARequired() && code != "" {
|
||||
// Dev convenience (matches setup): return the freshly minted code so
|
||||
// the request path is testable without grepping the backend log. The
|
||||
// code is never included when 2FA is enforced.
|
||||
resp["code"] = code
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("failed to encode 2FA code response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/user/2fa/disable
|
||||
// Turns 2FA off and clears method + pending fields for the authenticated user.
|
||||
//
|
||||
@@ -578,7 +658,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// The per-user mint cooldown still bounds how often a fresh code can be
|
||||
// minted — at most one per twoFAMintCooldown — but it cannot grant a fresh
|
||||
// guessing budget.
|
||||
if err := ensurePendingTwoFACode(r, userID, st); err != nil {
|
||||
if _, err := ensurePendingTwoFACode(r, userID, st, "disable 2FA"); err != nil {
|
||||
if errors.Is(err, errTwoFAMintThrottled) {
|
||||
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
@@ -625,8 +705,16 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
|
||||
// code to verify against, generating + delivering a fresh one via the same
|
||||
// build-dependent delivery channel as setup (see deliverTwoFACode) when the
|
||||
// stored code is missing or expired. The caller must hold the user's
|
||||
// attempt-state mutex.
|
||||
// stored code is missing or expired. purpose labels the delivery for the [2FA]
|
||||
// log line (e.g. "disable 2FA", "saved-card charge"). The caller must hold the
|
||||
// user's attempt-state mutex.
|
||||
//
|
||||
// It returns the plaintext code only when a FRESH code was minted and
|
||||
// delivered (dev/test builds always deliver it; production builds only when
|
||||
// the operator opted into log delivery — see twofa_prod.go). When a valid
|
||||
// pending code was reused, the return is empty: only the digest is stored, so
|
||||
// the plaintext is unavailable. Callers must only expose the returned code in
|
||||
// unenforced environments (matching SetupTwoFAHandler's dev convenience).
|
||||
//
|
||||
// Minting a fresh code does NOT reset the failed-attempt counter (B11b): the
|
||||
// counter resets only on a successful verify or when the 10-minute attempt
|
||||
@@ -638,7 +726,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// who exhausts the budget must wait out the window, not the mint cooldown. A
|
||||
// failed delivery does not start the cooldown (the stamp is written only after
|
||||
// the UPDATE persisted).
|
||||
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState) error {
|
||||
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, purpose string) (string, error) {
|
||||
var pendingHash sql.NullString
|
||||
var pendingExpires sql.NullTime
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
@@ -647,20 +735,21 @@ func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptStat
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&pendingHash, &pendingExpires)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
now := clock.Now()
|
||||
if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown {
|
||||
return errTwoFAMintThrottled
|
||||
return "", errTwoFAMintThrottled
|
||||
}
|
||||
if _, err := deliverTwoFACode(r, userID, "", "disable 2FA"); err != nil {
|
||||
return err
|
||||
code, err := deliverTwoFACode(r, userID, "", purpose)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
st.LastMintAt = now
|
||||
return nil
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
|
||||
|
||||
@@ -1251,6 +1251,140 @@ func TestTwoFADisableCode_UnenforcedStillMints(t *testing.T) {
|
||||
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")
|
||||
|
||||
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 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
|
||||
|
||||
Reference in New Issue
Block a user