fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment

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.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent a8bf24ee23
commit a6a4683b74
24 changed files with 1338 additions and 384 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ func TestPasswordChange_RevokesTokens(t *testing.T) {
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate refresh token: %v", err)
}
+30 -17
View File
@@ -508,7 +508,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
st.Mu.Lock()
defer st.Mu.Unlock()
if _, err := ensurePendingTwoFACode(r, userID, st, "disable 2FA"); 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
@@ -542,10 +542,15 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
// 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
// Contract: 200 {"message":"Code sent","remaining_seconds":N} (+ a dev-only
// "code" field when 2FA is unenforced, matching setup) where N is how many
// seconds the effective pending code stays valid — the FULL twoFAPendingExpiry
// after a fresh mint, or the decremented lifetime when an existing valid code
// was reused (LOW 5). A 200 with a reused code must NOT be read as "a new code
// was sent": the frontend should use the already-delivered code and show the
// countdown. 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.
@@ -575,7 +580,7 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
st.Mu.Lock()
defer st.Mu.Unlock()
code, err := ensurePendingTwoFACode(r, userID, st, "saved-card charge")
code, remaining, 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)
@@ -593,7 +598,11 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
resp := map[string]any{"message": "Code sent"}
// remaining_seconds tells the client how much longer the (possibly reused)
// pending code stays valid, so a button that would otherwise toast "Code
// sent" while no NEW code was minted can instead show a countdown / keep
// the existing code (LOW 5).
resp := map[string]any{"message": "Code sent", "remaining_seconds": int(remaining.Seconds())}
if !twoFARequired() && code != "" {
// Dev convenience (matches setup): return the freshly minted code so
// the request path is testable without grepping the backend log. The
@@ -658,7 +667,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, "disable 2FA"); 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
@@ -712,9 +721,13 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// 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).
// pending code was reused, the returned code 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). The remaining lifetime of the effective pending code (the
// reused one, or the fresh mint's full twoFAPendingExpiry) is always returned
// so a caller can surface "code still valid for N seconds" instead of implying
// a fresh code was sent (LOW 5).
//
// 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
@@ -726,7 +739,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, purpose string) (string, error) {
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, purpose string) (string, time.Duration, error) {
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
@@ -735,21 +748,21 @@ func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptStat
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil {
return "", err
return "", 0, err
}
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
return "", nil
return "", pendingExpires.Time.Sub(clock.Now()), nil
}
now := clock.Now()
if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown {
return "", errTwoFAMintThrottled
return "", 0, errTwoFAMintThrottled
}
code, err := deliverTwoFACode(r, userID, "", purpose)
if err != nil {
return "", err
return "", 0, err
}
st.LastMintAt = now
return code, nil
return code, twoFAPendingExpiry, nil
}
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
+11
View File
@@ -1275,6 +1275,7 @@ func TestTwoFASendVerificationCode_Enabled_MintsFresh(t *testing.T) {
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
@@ -1302,6 +1303,16 @@ func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) {
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)