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
+66 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
@@ -14,14 +15,17 @@ import (
"os"
"time"
"crussell/auth"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/dav"
"crussell/internal/s3"
"crussell/internal/square"
"crussell/internal/twofa"
"crussell/mw"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
// --- Square erasure retry + alerting + durable outbox (GDPR H3 / A1) ---
@@ -201,6 +205,15 @@ func clearSquareErasureOutboxRows(ctx context.Context, rowIDs []string) {
}
}
// DeleteAccountRequest carries the re-verification credentials the handler now
// requires before erasing an account (finding 3): the current password (always)
// and, in enforced environments for a user with 2FA enabled, a fresh one-time
// verification code.
type DeleteAccountRequest struct {
CurrentPassword string `json:"current_password"`
VerificationCode string `json:"verification_code"`
}
// DELETE /api/user/account
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
@@ -211,8 +224,10 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
var accountRole string
var profilePicURL sql.NullString
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
Scan(&accountRole, &profilePicURL)
var passwordHash sql.NullString
var twoFactorEnabled bool
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url, password_hash, two_factor_enabled FROM users WHERE id = $1`, userID).
Scan(&accountRole, &profilePicURL, &passwordHash, &twoFactorEnabled)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "user not found", http.StatusNotFound)
@@ -225,6 +240,48 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Finding 3: deleting an account is irreversible, so the session token alone
// must not be enough — an attacker who lifts a token (XSS, leaked localStorage)
// must not be able to erase the account. Re-verify the current password
// (mirroring ChangePasswordHandler's bcrypt compare) and, in enforced
// environments for a user with 2FA enabled, a fresh one-time code consumed by
// the shared core (twofa.VerifyForUser with ConsumeOnVerify).
var req DeleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if passwordHash.Valid && passwordHash.String != "" {
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash.String), []byte(req.CurrentPassword)); err != nil {
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
return
}
}
if twoFARequired() && twoFactorEnabled {
if req.VerificationCode == "" {
http.Error(w, "a two-factor verification code is required to delete the account", http.StatusBadRequest)
return
}
switch err := twofa.VerifyForUser(ctx, userID, req.VerificationCode, twofa.ConsumeOnVerify); {
case err == nil:
// Verified: the code is consumed (single-use), matching the saved-card
// gate. If the deletion below then fails the user requests a fresh code.
case errors.Is(err, twofa.ErrIncorrect):
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
case errors.Is(err, twofa.ErrLockedOut):
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case errors.Is(err, twofa.ErrMissingOrExpired):
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return
default:
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
}
// --- External system scrubbing (BEFORE SQL anonymize) ---
// Delete profile picture from S3/R2
@@ -393,6 +450,13 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
// columns are NULLed, but the cache is never touched by either).
payments.InvalidateSquareCustomerCache(userID)
// Finding 5: anonymize_user()/delete_guest_user() deleted every refresh
// token the user held inside the committed transaction. Drop the in-memory
// family-alive verdicts for ALL of the user's rotation families so access
// tokens minted by those families die on their next verification instead of
// riding the 30s family-alive cache TTL.
auth.InvalidateFamilyAliveByUser(userID)
// Build the context used for critical-notification inserts: route through
// the request transaction when one is active (tests) so alerts roll back
// with the fixture; otherwise fall back to the shared pool (production,