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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user