fix: dup/mod secondary round — till 2FA gate parity, mint-cooldown single source, notification parity, pence comments
Loop B dup/mod attack findings:
- TillPurchases admin 2FA gate now mirrors PaymentModal and the backend: gateActive = twoFactorEnforced && customerTwoFactorEnabled && paymentMethod='saved_card'; the customer's setup flag is fetched from GET /api/admin/users/{id} on selection. A 2FA-disabled customer in an enforced env no longer hits a dead-end blocked input — the charge 403 surfaces the actionable message via the existing self-heal.
- Extracted twoFAMintThrottled helper shared by SetupTwoFAHandler and ensurePendingTwoFACode — mint-cooldown rule can no longer drift between setup and disable-flow paths
- Notification-helper drift documented: sweep copy states the intentional booking+user scoping vs the canonical webhook copy (cross-referenced); auth refresh_token_reuse insert verified to carry the same NOT EXISTS acknowledged_at IS NULL guard; no import cycle (webhooks→payments one-way)
- Pence convention: 'rounded to the cent' corrected to 'pence' (handlers.go:2726)
26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -106,6 +106,15 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
|
||||
return twofa.StateFor(userID)
|
||||
}
|
||||
|
||||
// twoFAMintThrottled reports whether a fresh 2FA code mint for the user is
|
||||
// still inside the per-user cooldown window (twoFAMintCooldown): a previous
|
||||
// mint within the window throttles the request (429) instead of minting
|
||||
// another code. Shared by SetupTwoFAHandler and ensurePendingTwoFACode so the
|
||||
// cooldown rule cannot drift between the setup and disable-flow call paths.
|
||||
func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool {
|
||||
return !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown
|
||||
}
|
||||
|
||||
// twoFAResetAttempts zeroes the shared per-user attempt counter in place.
|
||||
// Called on successful verify only — a fresh code mint must NOT reset it (B11b).
|
||||
func twoFAResetAttempts(userID string) {
|
||||
@@ -260,13 +269,14 @@ func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
st.Mu.Lock()
|
||||
defer st.Mu.Unlock()
|
||||
|
||||
// Mint cooldown (B11a): the same twoFAMintCooldown guard the disable flow
|
||||
// applies via ensurePendingTwoFACode now bounds setup re-mints too. A fresh
|
||||
// setup code no longer resets the failed-attempt counter (B11b), so without
|
||||
// this a setup-spam loop could mint fresh codes (each invalidating the
|
||||
// prior lockout state) and keep a guessing budget alive indefinitely.
|
||||
// Mint cooldown (B11a): the shared twoFAMintThrottled helper bounds setup
|
||||
// re-mints — the same guard the disable flow applies via
|
||||
// ensurePendingTwoFACode. A fresh setup code no longer resets the
|
||||
// failed-attempt counter (B11b), so without this a setup-spam loop could
|
||||
// mint fresh codes (each invalidating the prior lockout state) and keep a
|
||||
// guessing budget alive indefinitely.
|
||||
now := clock.Now()
|
||||
if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown {
|
||||
if twoFAMintThrottled(st, now) {
|
||||
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
@@ -821,7 +831,7 @@ func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptStat
|
||||
return "", pendingExpires.Time.Sub(clock.Now()), nil
|
||||
}
|
||||
now := clock.Now()
|
||||
if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown {
|
||||
if twoFAMintThrottled(st, now) {
|
||||
return "", 0, errTwoFAMintThrottled
|
||||
}
|
||||
code, err := deliverTwoFACode(r, userID, "", purpose)
|
||||
|
||||
Reference in New Issue
Block a user