fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup

Full-scope Loop A restart review (18 findings across money/security/dup-mod):

MONEY:
- HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking
- MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount
- MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit
- MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx)
- LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded

SECURITY:
- 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure)
- Admin 2FA mint now writes admin_audit_log + logs code reuse
- Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account
- Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts)
- family-alive cache invalidated on password change / GDPR erasure
- Login lockout keyed per user+IP with a capped ceiling

FRONTEND/DUP-MOD:
- OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware)
- PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard)
- requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode)
- BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently

26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent b46927336b
commit 9a182db932
27 changed files with 1279 additions and 300 deletions
+75 -11
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log"
"log/slog"
"math/big"
"net/http"
"time"
@@ -109,7 +110,7 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
// 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
// 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
@@ -271,7 +272,7 @@ func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// 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
// 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.
@@ -480,7 +481,7 @@ func writeTwoFAEnabled(w http.ResponseWriter) {
// recover after a short wait.
const twoFAMintCooldown = 1 * time.Minute
// errTwoFAMintThrottled is returned by ensurePendingTwoFACode when the user's
// errTwoFAMintThrottled is returned by EnsurePendingTwoFACode when the user's
// last disable-flow mint is inside twoFAMintCooldown, so the caller returns 429
// instead of minting another fresh code.
var errTwoFAMintThrottled = errors.New("2FA code mint throttled")
@@ -521,7 +522,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
@@ -548,7 +549,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
// 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
// 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
@@ -593,7 +594,7 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
st.Mu.Lock()
defer st.Mu.Unlock()
code, remaining, 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)
@@ -627,6 +628,44 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
}
}
// insertAdminAudit records an admin action in admin_audit_log. Mirrors the
// insertAdminAuditCharge pattern (handlers/payments/handlers.go) — same table,
// same columns, same best-effort non-fatal failure handling. The insert runs in
// its OWN transaction (a savepoint in the test harness) so an audit-write
// failure — e.g. a synthetic admin id in tests violating the admin_id FK —
// rolls back only the audit write and can never abort the caller's transaction.
func insertAdminAudit(ctx context.Context, adminID, targetUserID, action string, details map[string]any) {
detailsJSON, err := json.Marshal(details)
if err != nil {
log.Printf("Failed to marshal admin_audit_log details (non-critical): %v", err)
return
}
var target any
if targetUserID != "" {
target = targetUserID
}
auditTx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
return
}
defer func() {
if err := auditTx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback admin audit transaction", "err", err)
}
}()
if _, err := auditTx.Exec(ctx, `
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
VALUES ($1, $2, $3, $4::jsonb)
`, adminID, action, target, string(detailsJSON)); err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
return
}
if err := auditTx.Commit(ctx); err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
}
}
// AdminSendVerificationCodeHandler mints (or reuses) a 2FA code for a TARGET
// user, not the session user. The saved-card charge gate verifies the code
// against the CARD OWNER (customer) — never the admin session (till.go:951,
@@ -634,6 +673,10 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
// and could never authorize the customer's charge. Delivering keyed to the
// customer preserves the invariant that the customer, not the admin, is the
// authentication subject for their card.
//
// Finding 2: every successful mint-or-reuse writes an admin_audit_log row
// (action_type '2fa_code_mint', details carrying reused vs fresh + the
// remaining code lifetime), so admin-scoped code issuance is never silent.
func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
targetUserID := chi.URLParam(r, "id")
if targetUserID == "" {
@@ -664,7 +707,7 @@ func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
st.Mu.Lock()
defer st.Mu.Unlock()
code, remaining, err := ensurePendingTwoFACode(r, targetUserID, st, "saved-card charge")
code, remaining, err := EnsurePendingTwoFACode(r, targetUserID, 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)
@@ -679,6 +722,16 @@ func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Finding 2a: the admin who minted/reused the code is audited. The empty
// `code` return discriminates reuse from a fresh mint — the plaintext is
// only returned for a fresh delivery, never on reuse (EnsurePendingTwoFACode).
if adminID, ok := mw.GetUserID(r.Context()); ok {
insertAdminAudit(r.Context(), adminID, targetUserID, "2fa_code_mint", map[string]any{
"reused": code == "",
"remaining_seconds": int(remaining.Seconds()),
})
}
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
@@ -744,7 +797,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
@@ -775,7 +828,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case twoFACodeMissingOrExpired:
// ensurePendingTwoFACode just guaranteed a valid pending code; defensive.
// EnsurePendingTwoFACode just guaranteed a valid pending code; defensive.
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return
}
@@ -788,13 +841,20 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
// 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. 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.
//
// Exported so the finding-1 re-mint contract is referenceable: a saved-card
// charge gate that burns a code at verify time (twofa.ConsumeOnVerify) re-mints
// on a failed charge through this function. handlers/payments cannot import
// this package (import cycle), so payments reaches it via the HTTP mint
// endpoints (POST /user/2fa/code, POST /admin/users/{id}/2fa/code) that
// delegate to it.
//
// 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
@@ -816,7 +876,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, time.Duration, 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(), `
@@ -828,6 +888,10 @@ func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptStat
return "", 0, err
}
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
// LOW 5 / finding 2: a reused code is NOT re-delivered (the plaintext is
// unavailable — only the digest is stored), but it must still leave a
// trace so silent code-reuse is audit-visible in the [2FA] log stream.
log.Printf("[2FA] code reused (user=%s, purpose=%s, remaining=%s) — no fresh code minted or delivered", userID, purpose, pendingExpires.Time.Sub(clock.Now()).Round(time.Second))
return "", pendingExpires.Time.Sub(clock.Now()), nil
}
now := clock.Now()