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