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
+21
View File
@@ -229,6 +229,27 @@ func InvalidateFamilyAlive(familyID string) {
} }
} }
// InvalidateFamilyAliveByUser drops every cached family-alive verdict for a
// user, so access tokens bound to ANY of the user's rotation families are
// re-checked against the DB on their next verification. Called when a user's
// credentials die wholesale — a password change deletes every refresh token
// the user holds, and GDPR erasure does the same inside anonymize_user() /
// delete_guest_user() — so killed families' access tokens die immediately
// instead of riding the familyAliveCacheTTL (LOW 5).
func InvalidateFamilyAliveByUser(userID string) {
if userID == "" {
return
}
familyAliveCache.mu.Lock()
defer familyAliveCache.mu.Unlock()
suffix := "|" + userID
for k := range familyAliveCache.m {
if strings.HasSuffix(k, suffix) {
delete(familyAliveCache.m, k)
}
}
}
// CleanupRevokedJTIs removes expired entries from PostgreSQL and returns the count of deleted rows. // CleanupRevokedJTIs removes expired entries from PostgreSQL and returns the count of deleted rows.
func CleanupRevokedJTIs(ctx context.Context) (int, error) { func CleanupRevokedJTIs(ctx context.Context) (int, error) {
if db.Conn == nil { if db.Conn == nil {
+27
View File
@@ -872,3 +872,30 @@ func TestVerifyToken_WrongRoleType(t *testing.T) {
t.Errorf("expected 'invalid role claim' error, got: %v", err) t.Errorf("expected 'invalid role claim' error, got: %v", err)
} }
} }
// TestInvalidateFamilyAliveByUser pins LOW 5: dropping the cached family-alive
// verdicts for a USER (password change / GDPR erasure) removes every family of
// that user while leaving other users' families untouched.
func TestInvalidateFamilyAliveByUser(t *testing.T) {
t.Cleanup(func() {
familyAliveCache.mu.Lock()
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
familyAliveCache.mu.Unlock()
})
familyAliveStore("family-a|user-1", true)
familyAliveStore("family-b|user-1", true)
familyAliveStore("family-c|user-2", true)
InvalidateFamilyAliveByUser("user-1")
_, okA := familyAliveLookup("family-a|user-1")
_, okB := familyAliveLookup("family-b|user-1")
_, okC := familyAliveLookup("family-c|user-2")
require.False(t, okA, "user-1's family-a verdict must be dropped")
require.False(t, okB, "user-1's family-b verdict must be dropped")
require.True(t, okC, "user-2's family-c verdict must survive")
// An empty user id is a no-op, never a panic.
InvalidateFamilyAliveByUser("")
}
+51
View File
@@ -1818,6 +1818,57 @@ func TestLogin_AccountLockout_ResetsOnSuccess(t *testing.T) {
} }
} }
// TestLogin_AccountLockout_CappedAt30Min verifies the LOW-6 fix: the lockout
// ceiling never exceeds 30 minutes no matter how many failed attempts pile up
// (previously 20+ failures locked the account for 2 hours — a persistent,
// repeatedly-extendable DoS window for a guessing attacker).
func TestLogin_AccountLockout_CappedAt30Min(t *testing.T) {
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
userID, err := fixtures.CreateTestUserWithEmail(tx, "cap-test@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
defer tx.Exec(ctx, "UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1", userID)
// Simulate 20 prior failures — the old CASE locked for 2 hours here.
_, err = tx.Exec(ctx, "UPDATE users SET failed_attempts = 20 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to seed failed_attempts: %v", err)
}
body := LoginRequest{
Email: "cap-test@test.com",
Password: "wrongpassword",
}
// First wrong attempt computes and sets the (now capped) lock.
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 on the lock-setting attempt, got %d. body: %s", w.Code, w.Body.String())
}
// Second attempt hits the active lock → 429.
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d. body: %s", w.Code, w.Body.String())
}
var lockedUntil *time.Time
err = tx.QueryRow(ctx,
"SELECT locked_until FROM users WHERE id = $1", userID).Scan(&lockedUntil)
if err != nil {
t.Fatalf("failed to query locked_until: %v", err)
}
if lockedUntil == nil {
t.Fatal("expected locked_until to be set")
}
if until := lockedUntil.Sub(clock.Now()); until > 30*time.Minute {
t.Errorf("lockout must never exceed the 30-minute ceiling, got %v", until)
}
}
// ============================================================================= // =============================================================================
// JWT Auth Unit Tests (new from security pass) // JWT Auth Unit Tests (new from security pass)
// ============================================================================= // =============================================================================
+12 -2
View File
@@ -394,8 +394,6 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
SET failed_attempts = failed_attempts + 1, SET failed_attempts = failed_attempts + 1,
locked_until = CASE locked_until = CASE
WHEN failed_attempts + 1 >= 5 THEN NOW() + (CASE WHEN failed_attempts + 1 >= 5 THEN NOW() + (CASE
WHEN failed_attempts + 1 >= 20 THEN INTERVAL '2 hours'
WHEN failed_attempts + 1 >= 10 THEN INTERVAL '1 hour'
WHEN failed_attempts + 1 >= 7 THEN INTERVAL '30 minutes' WHEN failed_attempts + 1 >= 7 THEN INTERVAL '30 minutes'
ELSE INTERVAL '15 minutes' ELSE INTERVAL '15 minutes'
END) END)
@@ -426,6 +424,18 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// On success, clear lockout and update last_login // On success, clear lockout and update last_login
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear // TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
// failed_attempts and locked_until — a locked-out user can't call this handler. // failed_attempts and locked_until — a locked-out user can't call this handler.
//
// LOW 6 documented gap (finding 6): there is NO password-reset UI — the
// backend-only reset flow (GenerateVerificationCodeHandler/VerifyCodeHandler)
// has no frontend link, so a user locked out by a guessing attacker has no
// self-service recovery until locked_until lapses (capped at 30 minutes —
// see the failure path above); the operator can only intervene at the DB.
// The lockout counter stays keyed per-user (not per-(user,IP)) because this
// codebase deliberately rejects IP-in-the-key for account-level budgets (see
// the 2FA limiter note in main.go, B8): a client that rotates its source IP
// would mint a fresh bucket per IP and collapse the per-account budget. The
// 30-minute ceiling is the bounded-DoS compromise; successful 2FA verifies
// also clear the lockout (internal/twofa.Check).
tx, err := db.Conn.Begin(r.Context()) tx, err := db.Conn.Begin(r.Context())
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
+6 -6
View File
@@ -442,8 +442,8 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
FROM bookings b FROM bookings b
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS amount_paid, COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND payment_type <> 'tip'), 0) AS amount_paid,
COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < b.start_time), 0) AS pre_start_amount_paid COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND payment_type <> 'tip' AND created_at < b.start_time), 0) AS pre_start_amount_paid
FROM payments FROM payments
WHERE booking_id = b.id WHERE booking_id = b.id
) pt ON true` + whereClause ) pt ON true` + whereClause
@@ -655,7 +655,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
booking_id, booking_id,
SUM(amount) AS total_paid SUM(amount) AS total_paid
FROM payments FROM payments
WHERE status = 'completed' WHERE status = 'completed' AND payment_type <> 'tip'
GROUP BY booking_id GROUP BY booking_id
) )
SELECT SELECT
@@ -1056,8 +1056,8 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
paymentRows, err := db.Conn.Query(r.Context(), ` paymentRows, err := db.Conn.Query(r.Context(), `
SELECT p.booking_id, SELECT p.booking_id,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed'), 0) AS amount_paid, COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type <> 'tip'), 0) AS amount_paid,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.created_at < b.start_time), 0) AS pre_start_paid COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type <> 'tip' AND p.created_at < b.start_time), 0) AS pre_start_paid
FROM payments p FROM payments p
JOIN bookings b ON b.id = p.booking_id JOIN bookings b ON b.id = p.booking_id
WHERE p.booking_id = ANY($1) WHERE p.booking_id = ANY($1)
@@ -1752,7 +1752,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
booking_id, booking_id,
SUM(amount) AS total_paid SUM(amount) AS total_paid
FROM payments FROM payments
WHERE status = 'completed' WHERE status = 'completed' AND payment_type <> 'tip'
GROUP BY booking_id GROUP BY booking_id
) )
SELECT SELECT
+125 -18
View File
@@ -17,7 +17,9 @@ import (
"log" "log"
"log/slog" "log/slog"
"math" "math"
"math/big"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -2127,8 +2129,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// campaign credit): a payment that exceeds the raw remaining but stays // campaign credit): a payment that exceeds the raw remaining but stays
// within the discounted remaining is covered by the discount — it is NOT an // within the discounted remaining is covered by the discount — it is NOT an
// overflow into tip territory. // overflow into tip territory.
// remainingPence is the booking's tip-excluded outstanding balance
// (total - completed real payments, refunds re-open capacity). It is
// computed once here — before any pending row exists — and reused by both
// the overflow guard below and the A6 clamp-up cap on the deposit charge.
var remainingPence int64
if req.PaymentType != "tip" { if req.PaymentType != "tip" {
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID) var err error
remainingPence, err = service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if err != nil { if err != nil {
log.Printf("Failed to get remaining balance: %v", err) log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -2183,14 +2191,24 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
if req.PaymentType == "deposit" && eligibleDiscountPence > 0 { if req.PaymentType == "deposit" && eligibleDiscountPence > 0 {
chargeAmount = req.Amount - eligibleDiscountPence chargeAmount = req.Amount - eligibleDiscountPence
// A6: when the eligible discount is >= the deposit itself, chargeAmount // A6: when the eligible discount is >= the deposit itself, chargeAmount
// clamps UP to the full (undiscounted) deposit. The customer still pays // clamps UP the customer still pays something up front — but NEVER
// the full deposit up front — the discount credit applies to the // beyond the discounted obligation: the cap max(0, (totalPence -
// residual balance via the discount row created by // eligibleDiscountPence) - realPaidPence) equals remainingPence -
// applyEligibleCampaignsAtPayment (which runs regardless of // eligibleDiscountPence (remainingPence is the tip-excluded unpaid
// chargeAmount), so no discount is ever lost and the ledger can never // balance, total - realPaid). Without the cap, chargeAmount clamps to
// charge a negative amount. // the full undiscounted deposit and the headroom computation
// (discountHeadroomPence, which counts this pending charge) truncates
// the discount — the booking auto-completes with the customer overpaying
// by the truncated difference.
if chargeAmount <= 0 { if chargeAmount <= 0 {
chargeAmount = req.Amount chargeAmount = req.Amount
cap := remainingPence - eligibleDiscountPence
if cap < 0 {
cap = 0
}
if chargeAmount > cap {
chargeAmount = cap
}
} }
} }
@@ -2198,13 +2216,16 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
var savedCardID *string var savedCardID *string
var savedCardCustomerID string var savedCardCustomerID string
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is // 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
// enforced. New-card (nonce) charges are not gated. consume=false // enforced. New-card (nonce) charges are not gated. consume=!reusePendingRecord
// (MEDIUM-2): the code is verified here but only NULLed inside the // (LOW 6a): a FRESH charge verifies WITH consumption — the code is single-use
// completed-charge transaction below (ConsumePendingCode), so a // at the gate, closing the TOCTOU where a verified-but-unconsumed code could
// failed/ambiguous Square charge does NOT burn the operator-relayed code // authorize a second charge within its lifetime — and a failed Square charge
// and a same-key retry can re-verify the SAME code. // re-issues a fresh code (reissueTwoFACodeAfterFailedCharge). A pending-reuse
// retry verifies WITHOUT consuming: the code was re-issued for exactly this
// retry and the completed-charge transaction consumes it on terminal success,
// so a retry that fails again keeps its code for one more attempt.
if req.CardID != nil && *req.CardID != "" { if req.CardID != nil && *req.CardID != "" {
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, false) { if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, !reusePendingRecord) {
return return
} }
} }
@@ -2223,13 +2244,20 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// would otherwise re-charge). It also releases the DB transaction before // would otherwise re-charge). It also releases the DB transaction before
// the ~30s Square round-trip instead of holding it open across the call. // the ~30s Square round-trip instead of holding it open across the call.
if !reusePendingRecord { if !reusePendingRecord {
// MEDIUM-HIGH: the pending row stores the CHARGE amount, not the
// requested amount — a deposit-with-discount charge (chargeAmount =
// req.Amount - eligibleDiscountPence) differs from req.Amount, and the
// sweep's replayMatchesRowAmount (sweep.go) compares the replayed
// Square charge against this column. Storing req.Amount here would
// misclassify the ORIGINAL charge as a new expired-key replay and
// auto-refund the customer's legitimate payment (B1).
fees := service.CalculateFees(req.Amount, "online") fees := service.CalculateFees(req.Amount, "online")
pendingRecord := PaymentRecord{ pendingRecord := PaymentRecord{
BookingID: bookingID, BookingID: bookingID,
PaymentType: req.PaymentType, PaymentType: req.PaymentType,
PaymentMethod: "online_square", PaymentMethod: "online_square",
Status: "pending", Status: "pending",
Amount: float64(req.Amount) / 100.0, Amount: float64(chargeAmount) / 100.0,
IdempotencyKey: &req.IdempotencyKey, IdempotencyKey: &req.IdempotencyKey,
Fees: float64(fees) / 100.0, Fees: float64(fees) / 100.0,
UserSavedCardID: savedCardID, UserSavedCardID: savedCardID,
@@ -2325,6 +2353,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq) paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil { if err != nil {
log.Printf("Failed to create payment: %v (error_code=%q)", err, square.ErrorCode(err)) log.Printf("Failed to create payment: %v (error_code=%q)", err, square.ErrorCode(err))
// The gate consumed the 2FA code for a fresh charge — re-issue so the
// same-key retry has a live code to verify.
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
http.Error(w, "Payment failed", chargeFailureStatus(err)) http.Error(w, "Payment failed", chargeFailureStatus(err))
return return
} }
@@ -2467,10 +2498,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// MEDIUM-2: a saved-card charge reached its terminal SUCCESS state — // MEDIUM-2: a saved-card charge reached its terminal SUCCESS state —
// consume the verified 2FA code now, inside the same transaction that // consume the verified 2FA code now, inside the same transaction that
// records the completed charge (the gate verified without consuming, so a // records the completed charge. For a FRESH charge the gate already
// failed charge would not burn the code and a same-key retry could reuse // consumed the code, so this is an idempotent no-op safety net; for a
// it). Only runs for saved-card (CardID) charges — the gate only ran for // pending-reuse retry (gate passed consume=false — the code was re-issued
// those, and new-card charges have no code to consume. // for this retry) this is where it is burned, so a retry that fails again
// keeps its code for one more attempt. Only runs for saved-card (CardID)
// charges — the gate only ran for those, and new-card charges have no code
// to consume.
if req.CardID != nil && *req.CardID != "" { if req.CardID != nil && *req.CardID != "" {
if consErr := twofa.ConsumePendingCode(r.Context(), tx2, userID); consErr != nil { if consErr := twofa.ConsumePendingCode(r.Context(), tx2, userID); consErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required", log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
@@ -2637,6 +2671,26 @@ func refundLostCampaignAsBalanceCredit(ctx context.Context, bookingID, userID st
insertCriticalPaymentNotification(ctx, &bookingID, &userID) insertCriticalPaymentNotification(ctx, &bookingID, &userID)
return fmt.Sprintf("credit of £%.2f FAILED (manual reconciliation required)", creditPounds) return fmt.Sprintf("credit of £%.2f FAILED (manual reconciliation required)", creditPounds)
} }
// Track the credit in gift_card_transactions (reference_type
// 'b13_campaign_loss', reference_id = booking) so a later cancellation can
// reverse it (clawbackB13CampaignCredit). The row is anchored to a real
// gift card of the user because the table requires one; a user with no gift
// card still gets the balance credit but no audit row — the clawback then
// has nothing to reverse.
var anchorCardID string
if err := db.Conn.QueryRow(ctx, `
SELECT id FROM gift_cards
WHERE created_by = $1 OR redeemed_by = $1
ORDER BY COALESCE(redeemed_at, created_at) DESC, created_at DESC
LIMIT 1
`, userID).Scan(&anchorCardID); err == nil && anchorCardID != "" {
if _, err := db.Conn.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'balance_credit', $2, 'b13_campaign_loss', $3, $4, $5)
`, anchorCardID, creditPounds, bookingID, userID, "B13 campaign-loss balance credit"); err != nil {
log.Printf("B13: failed to record gift_card_transactions credit for user %s (booking %s): %v", userID, bookingID, err)
}
}
return fmt.Sprintf("credited £%.2f to gift-card account balance", creditPounds) return fmt.Sprintf("credited £%.2f to gift-card account balance", creditPounds)
} }
@@ -4942,3 +4996,56 @@ func randomHexSuffix(n int) string {
} }
return fmt.Sprintf("%x", b) return fmt.Sprintf("%x", b)
} }
// reissueTwoFACodeAfterFailedCharge mints a fresh 2FA code after a saved-card
// charge failed at Square. The charge gate consumes the verified code at gate
// time for fresh charges (single-use — closing the verify-then-consume TOCTOU
// where a verified-but-unconsumed code could authorize a second charge), so a
// failed charge leaves no live code for the same-key retry; this re-issues one
// with the same 10-minute lifetime and delivery behaviour as the user
// package's code issuance (dev/test logs the code for the operator to relay;
// production logs only with TWO_FACTOR_ALLOW_LOG_DELIVERY=true, matching the
// fail-closed delivery contract). Best-effort: a failure logs and the customer
// requests a fresh code through the normal 2FA flow. Idempotent by design — a
// pending-reuse retry whose code was NOT consumed also gets a fresh, longer-
// lived code, which never invalidates anything that still needed verifying.
func reissueTwoFACodeAfterFailedCharge(ctx context.Context, userID string) {
if userID == "" || !twoFactorEnforced() {
return
}
code, err := generatePaymentsTwoFACode()
if err != nil {
log.Printf("2FA: failed to generate a re-issued code for user %s after a failed charge: %v", userID, err)
return
}
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1
`, userID, twofa.Hash(code), clock.Now().Add(twoFAPendingCodeLifetime)); err != nil {
log.Printf("2FA: failed to store a re-issued code for user %s after a failed charge: %v", userID, err)
return
}
// Delivery mirrors the user package's build-dependent behaviour (the
// operator relays the [2FA] log line). Production logs the plaintext code
// only when explicitly opted in; dev/test always.
if IsExplicitDevOrMockEnv() || os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
log.Printf("[2FA] code delivery requested (user=%s, purpose=re-issue after failed saved-card charge)", userID)
log.Printf("[2FA] code: %s", code)
}
}
// generatePaymentsTwoFACode returns a random 6-digit verification code,
// mirroring the user package's generator (crypto/rand, uniform 0-999999).
func generatePaymentsTwoFACode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// twoFAPendingCodeLifetime is how long a re-issued 2FA code stays valid,
// mirroring the user package's pending-code expiry.
const twoFAPendingCodeLifetime = 10 * time.Minute
+71
View File
@@ -703,11 +703,82 @@ func ProcessCancellationRefundTx(
log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID) log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID)
} }
} }
// B13: the full payment refund is issued above; a campaign-loss balance
// credit granted at charge time (refundLostCampaignAsBalanceCredit) must
// be reversed here or the customer gets the money back AND keeps the
// promised-discount credit — a double credit. No-op when the booking
// never received a B13 credit.
clawbackB13CampaignCredit(ctx, tx, bookingID, bookingUserID)
} }
return &calc, nil return &calc, nil
} }
// clawbackB13CampaignCredit reverses a B13 campaign-loss balance credit
// (refundLostCampaignAsBalanceCredit) when the booking is cancelled: the
// customer receives the full payment refund above, so keeping the promised-
// discount credit on their gift-card balance too would be a double credit. The
// credit is located via its gift_card_transactions audit row (reference_type
// 'b13_campaign_loss', reference_id = booking). The balance debit is guarded
// (balance >= amount) so a balance already spent below the credit is never
// driven negative; an insufficient balance is flagged for manual
// reconciliation instead of silently kept. Idempotent: an existing reversal
// row for the booking prevents a re-run from debiting twice.
func clawbackB13CampaignCredit(ctx context.Context, tx pgx.Tx, bookingID, userID string) {
if bookingID == "" || userID == "" {
return
}
var alreadyReversed bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM gift_card_transactions
WHERE reference_type = 'b13_campaign_loss' AND reference_id = $1 AND transaction_type = 'balance_debit'
)
`, bookingID).Scan(&alreadyReversed); err != nil {
log.Printf("B13: failed to check for an existing clawback of booking %s: %v", bookingID, err)
return
}
if alreadyReversed {
return
}
var creditPounds float64
if err := tx.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0) FROM gift_card_transactions
WHERE reference_type = 'b13_campaign_loss' AND reference_id = $1
`, bookingID).Scan(&creditPounds); err != nil || creditPounds <= 0 {
return
}
tag, err := tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = user_giftcard_balances.balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
`, creditPounds, userID)
if err != nil {
log.Printf("B13: failed to claw back £%.2f campaign-loss credit from user %s (booking %s): %v", creditPounds, userID, bookingID, err)
return
}
if tag.RowsAffected() == 0 {
log.Printf("CRITICAL: B13 campaign-loss credit of £%.2f for booking %s could not be clawed back from user %s (balance < amount — credit partially spent) — MANUAL RECONCILIATION REQUIRED", creditPounds, bookingID, userID)
return
}
// Record the reversal on the same anchor card as the credit for the audit
// trail (also serves as the idempotency marker above).
var anchorCardID string
if err := tx.QueryRow(ctx, `
SELECT gift_card_id FROM gift_card_transactions
WHERE reference_type = 'b13_campaign_loss' AND reference_id = $1
ORDER BY created_at DESC LIMIT 1
`, bookingID).Scan(&anchorCardID); err == nil && anchorCardID != "" {
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'balance_debit', $2, 'b13_campaign_loss', $3, $4, $5)
`, anchorCardID, creditPounds, bookingID, userID, "B13 campaign-loss credit clawed back on cancellation"); err != nil {
log.Printf("B13: failed to record clawback transaction for user %s (booking %s): %v", userID, bookingID, err)
}
}
log.Printf("B13: clawed back £%.2f campaign-loss balance credit from user %s after cancellation of booking %s", creditPounds, userID, bookingID)
}
// ProcessCancellationRefund calculates and records refunds for a cancelled // ProcessCancellationRefund calculates and records refunds for a cancelled
// booking, processing refunds against the booking's completed payments up to // booking, processing refunds against the booking's completed payments up to
// the calculated refundable amount. The wrapper owns its own transaction and // the calculated refundable amount. The wrapper owns its own transaction and
+34 -31
View File
@@ -665,23 +665,23 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
// creation and a replayed payment's creation for the payment to be the REAL // creation and a replayed payment's creation for the payment to be the REAL
// charge under a legitimately replayed key. A same-key retry — the documented // charge under a legitimately replayed key. A same-key retry — the documented
// retry path (handlers.go:1579-1591) — creates its charge somewhere between // retry path (handlers.go:1579-1591) — creates its charge somewhere between
// the row's creation and the sweep's 22h keyed cutoff (stalePendingKeyedAge), // the row's creation and the 24h retry-eligible window (the same-key retry
// so any COMPLETED payment created within [row.CreatedAt, row.CreatedAt + // path stays valid until Square's ~24h idempotency-key retention expires), so
// any COMPLETED payment created within [row.CreatedAt, row.CreatedAt +
// replayLegitimateRetryWindow] can be that retry charge and must be rescued. // replayLegitimateRetryWindow] can be that retry charge and must be rescued.
// //
// B2: the window is exactly stalePendingKeyedAge (22h), never shorter. The // The window is 24h — the full Square idempotency-key retention window. The
// sweep's own replay only runs once a row is at least 22h old, so a payment // sweep's own replay only runs once a row is at least 22h old
// created within 22h of the row can ONLY be a legitimate same-key retry (a // (stalePendingKeyedAge); a legitimate same-key retry can still land up to 24h
// 21h window left a dead zone at 21-22h where a legitimate retry was refused // after the row, and a shorter window would misclassify a retry landing in the
// and stranded the row pending). A payment created LATER than 22h after the // 22-24h zone as a NEW expired-key replay and auto-refund the customer's
// row is the classic expired-key replay-induced charge — the sweep just // legitimate charge (B1). A payment created LATER than 24h after the row is the
// created it by replaying the still valid saved-card source under a key Square // classic expired-key replay-induced charge — the sweep just created it by
// no longer retains — and rescuing it would hide the duplicate charge behind // replaying the still valid saved-card source under a key Square no longer
// the original row (finding A1). The boundary is inclusive: a payment created // retains — and rescuing it would hide the duplicate charge behind the
// EXACTLY 22h after the row is still within the legitimate window (the sweep // original row (finding A1). The boundary is inclusive: a payment created
// picks the row up at age >= 22h, so a retry landing at the very boundary was // EXACTLY 24h after the row is still within the legitimate window.
// created no later than the moment the sweep could first have replayed). const replayLegitimateRetryWindow = 24 * time.Hour
const replayLegitimateRetryWindow = 22 * time.Hour
// replayRescueLowerBoundSkew is the lower-bound tolerance for a replayed // replayRescueLowerBoundSkew is the lower-bound tolerance for a replayed
// COMPLETED payment to still be treated as the ORIGINAL charge under a retained // COMPLETED payment to still be treated as the ORIGINAL charge under a retained
@@ -712,15 +712,15 @@ func replayMatchesRowAmount(r staleRow, pr *square.PaymentResult) bool {
// replayWithinLegitimateWindow reports whether a replayed COMPLETED payment is // replayWithinLegitimateWindow reports whether a replayed COMPLETED payment is
// the REAL charge this pending row is waiting on — the ORIGINAL charge under a // the REAL charge this pending row is waiting on — the ORIGINAL charge under a
// retained key (created ~at row creation) or a later SAME-KEY RETRY charge // retained key (created ~at row creation) or a later SAME-KEY RETRY charge
// (created between the row's creation and the 22h sweep cutoff, F2). The // (created between the row's creation and the 24h retry-eligible window, F2).
// amount must match the row (a retry can never change it) and the payment must // The amount must match the row (a retry can never change it) and the payment
// have been created within replayLegitimateRetryWindow of the row. The source // must have been created within replayLegitimateRetryWindow of the row. The
// is matched by construction: the replay body is rebuilt from the row's stored // source is matched by construction: the replay body is rebuilt from the row's
// square_request_snapshot with the LIVE square_source_id override, so a payment // stored square_request_snapshot with the LIVE square_source_id override, so a
// returned by the replay necessarily charged the row's source (Square's // payment returned by the replay necessarily charged the row's source (Square's
// PaymentResult does not echo the source id back, so it cannot be compared // PaymentResult does not echo the source id back, so it cannot be compared
// directly). A payment created very near the sweep time (lag > 21h) is the // directly). A payment created very near the sweep time (lag > 23h) is most
// expired-key replay-induced charge and is NOT legitimate. // likely the expired-key replay-induced charge and is NOT legitimate.
func replayWithinLegitimateWindow(r staleRow, pr *square.PaymentResult) bool { func replayWithinLegitimateWindow(r staleRow, pr *square.PaymentResult) bool {
if !replayMatchesRowAmount(r, pr) { if !replayMatchesRowAmount(r, pr) {
return false return false
@@ -1066,8 +1066,11 @@ func sweepDuplicateRefundReasonFor(tillSaleID string) string {
// the customer never authorized. The refund reuses the existing Square refund // the customer never authorized. The refund reuses the existing Square refund
// path (SquareClient.RefundPayment) with: // path (SquareClient.RefundPayment) with:
// //
// - amount r.AmountPence — the row's charge amount, which the replay body // - amount pr.Amount — the replayed payment's ACTUAL charged amount. The row's
// repeats, so the duplicate charged exactly this; // stored amount (r.AmountPence) can differ when the original charge applied
// a deposit-with-discount (chargeAmount = requested - discount): the row
// records the discounted charge, and the replay repeats that discounted
// body, so the duplicate charged exactly pr.Amount;
// - a deterministic idempotency key derived from the replayed payment's id, // - a deterministic idempotency key derived from the replayed payment's id,
// so a re-run refunding the SAME duplicate dedups at Square instead of // so a re-run refunding the SAME duplicate dedups at Square instead of
// issuing a second refund, while a different duplicate on a later replay // issuing a second refund, while a different duplicate on a later replay
@@ -1095,8 +1098,8 @@ func refundSweepDuplicateCharge(ctx context.Context, table string, r staleRow, p
if pr == nil || pr.ID == "" { if pr == nil || pr.ID == "" {
return errors.New("replayed payment has no Square payment id to refund") return errors.New("replayed payment has no Square payment id to refund")
} }
if r.AmountPence <= 0 { if pr.Amount <= 0 {
return fmt.Errorf("refusing to auto-refund a non-positive amount %d pence for row %s", r.AmountPence, r.ID) return fmt.Errorf("refusing to auto-refund a non-positive amount %d pence for replayed payment %s (row %s)", pr.Amount, pr.ID, r.ID)
} }
// Deterministic per-duplicate key: Square dedups same-key refunds, so a // Deterministic per-duplicate key: Square dedups same-key refunds, so a
// re-run replaying the same C2 never double-refunds. "sweepdup-" + Square // re-run replaying the same C2 never double-refunds. "sweepdup-" + Square
@@ -1107,7 +1110,7 @@ func refundSweepDuplicateCharge(ctx context.Context, table string, r staleRow, p
} }
res, refundErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ res, refundErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: pr.ID, PaymentID: pr.ID,
Amount: r.AmountPence, Amount: pr.Amount,
IdempotencyKey: refundKey, IdempotencyKey: refundKey,
Reason: sweepDuplicateRefundReason, Reason: sweepDuplicateRefundReason,
}) })
@@ -1135,10 +1138,10 @@ func refundSweepDuplicateCharge(ctx context.Context, table string, r staleRow, p
insertCriticalPaymentNotification(ctx, r.BookingID, r.CreatedBy) insertCriticalPaymentNotification(ctx, r.BookingID, r.CreatedBy)
if pending { if pending {
log.Printf("Auto-refund of replay-induced duplicate charge %s (%d pence) for pending row %s is PENDING at Square (refund %s) — leaving the row pending for the refund re-poll", pr.ID, r.AmountPence, r.ID, res.ID) log.Printf("Auto-refund of replay-induced duplicate charge %s (%d pence) for pending row %s is PENDING at Square (refund %s) — leaving the row pending for the refund re-poll", pr.ID, pr.Amount, r.ID, res.ID)
return errSweepRefundPending return errSweepRefundPending
} }
log.Printf("Auto-refunded replay-induced duplicate charge %s (%d pence) for pending row %s — refund %s", pr.ID, r.AmountPence, r.ID, res.ID) log.Printf("Auto-refunded replay-induced duplicate charge %s (%d pence) for pending row %s — refund %s", pr.ID, pr.Amount, r.ID, res.ID)
return nil return nil
} }
@@ -1153,7 +1156,7 @@ func refundSweepDuplicateCharge(ctx context.Context, table string, r staleRow, p
// attached to it. The parent till_sale id is carried in the reason so the // attached to it. The parent till_sale id is carried in the reason so the
// re-poll pass claws it back when Square settles. // re-poll pass claws it back when Square settles.
func recordSweepDuplicateRefundRow(ctx context.Context, table string, r staleRow, pr *square.PaymentResult, refundID, status, refundKey string) { func recordSweepDuplicateRefundRow(ctx context.Context, table string, r staleRow, pr *square.PaymentResult, refundID, status, refundKey string) {
amountPounds := float64(r.AmountPence) / 100.0 amountPounds := float64(pr.Amount) / 100.0
paymentID := r.ID paymentID := r.ID
var bookingID *string var bookingID *string
reason := sweepDuplicateRefundReason reason := sweepDuplicateRefundReason
+30 -13
View File
@@ -9,7 +9,6 @@ import (
"testing" "testing"
"time" "time"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/internal/square" "crussell/internal/square"
"crussell/testutils" "crussell/testutils"
@@ -741,18 +740,24 @@ func TestSweepStalePendingPayments_KeyedReplayNewCharge_AutoRefunded(t *testing.
t.Fatalf("failed to create stale pending payment: %v", err) t.Fatalf("failed to create stale pending payment: %v", err)
} }
// 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but // 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but
// still inside Square's ~24h retention window (so the replay runs). NO // still inside Square's ~24h retention window (so the replay still runs —
// stored snapshot → the minimal fallback body is rebuilt, which skips the // a row past 24h is blind-failed without a replay). NO stored snapshot →
// production snapshot-decryption gate. created_by carries the payer so the // the minimal fallback body is rebuilt, which skips the production
// admin notification is attributable and assertable. // snapshot-decryption gate. created_by carries the payer so the admin
// notification is attributable and assertable.
const key = "key-expired-replay-new-charge" const key = "key-expired-replay-new-charge"
const dupPayID = "pay_expired_key_new_charge" const dupPayID = "pay_expired_key_new_charge"
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err) t.Fatalf("failed to age the stale payment: %v", err)
} }
var rowCreatedAt time.Time
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
t.Fatalf("failed to read aged payment created_at: %v", err)
}
// The replayed COMPLETED payment is created at sweep time (~23h after the // The replayed COMPLETED payment's created_at is 25h AFTER the pending row
// row) — the expired-key replay landed a NEW charge on the saved card. // — beyond the 24h legitimate-retry window (replayLegitimateRetryWindow),
// so it is provably a NEW expired-key replay charge, not a same-key retry.
// The cross-check runs only in a non-dev/mock env, so the env is flipped to // The cross-check runs only in a non-dev/mock env, so the env is flipped to
// production for the sweep (sequential, like the 2FA tests). The dev mock // production for the sweep (sequential, like the 2FA tests). The dev mock
// is constructed BEFORE the flip (NewDevClient refuses production without // is constructed BEFORE the flip (NewDevClient refuses production without
@@ -764,7 +769,8 @@ func TestSweepStalePendingPayments_KeyedReplayNewCharge_AutoRefunded(t *testing.
Status: "COMPLETED", Status: "COMPLETED",
ID: dupPayID, ID: dupPayID,
SquarePayID: dupPayID, SquarePayID: dupPayID,
CreatedAt: clock.Now().Format(time.RFC3339), Amount: 200000,
CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339),
}}} }}}
SquareClient = counting SquareClient = counting
defer func() { SquareClient = origClient }() defer func() { SquareClient = origClient }()
@@ -904,6 +910,10 @@ func TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowP
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err) t.Fatalf("failed to age the stale payment: %v", err)
} }
var rowCreatedAt time.Time
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
t.Fatalf("failed to read aged payment created_at: %v", err)
}
origClient := SquareClient origClient := SquareClient
mock := square.NewDevClient() mock := square.NewDevClient()
@@ -912,7 +922,8 @@ func TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowP
Status: "COMPLETED", Status: "COMPLETED",
ID: dupPayID, ID: dupPayID,
SquarePayID: dupPayID, SquarePayID: dupPayID,
CreatedAt: clock.Now().Format(time.RFC3339), Amount: 200000,
CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339),
}}} }}}
defer func() { SquareClient = origClient }() defer func() { SquareClient = origClient }()
@@ -1010,6 +1021,10 @@ func TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_NoClaw
if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil { if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
t.Fatalf("failed to age the gift card: %v", err) t.Fatalf("failed to age the gift card: %v", err)
} }
var saleCreatedAt time.Time
if err := tx.QueryRow(ctx, "SELECT created_at FROM till_sales WHERE id = $1", saleID).Scan(&saleCreatedAt); err != nil {
t.Fatalf("failed to read aged till sale created_at: %v", err)
}
origClient := SquareClient origClient := SquareClient
mock := square.NewDevClient() mock := square.NewDevClient()
@@ -1018,7 +1033,8 @@ func TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_NoClaw
Status: "COMPLETED", Status: "COMPLETED",
ID: dupPayID, ID: dupPayID,
SquarePayID: dupPayID, SquarePayID: dupPayID,
CreatedAt: clock.Now().Format(time.RFC3339), Amount: 5000,
CreatedAt: saleCreatedAt.Add(25 * time.Hour).Format(time.RFC3339),
}}} }}}
defer func() { SquareClient = origClient }() defer func() { SquareClient = origClient }()
@@ -1476,8 +1492,9 @@ func TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues(t *testi
// dead-zone fix: a same-key retry whose charge landed 21.5h after the pending // dead-zone fix: a same-key retry whose charge landed 21.5h after the pending
// row (between the old 21h window and the 22h sweep cutoff) is the REAL charge // row (between the old 21h window and the 22h sweep cutoff) is the REAL charge
// under a legitimately replayed key and MUST be rescued. The pre-B2 21h window // under a legitimately replayed key and MUST be rescued. The pre-B2 21h window
// refused it and stranded the row pending. 22h is the boundary: only a payment // refused it and stranded the row pending. The legitimate-retry boundary is now
// created AFTER row.CreatedAt+22h can be the sweep's own expired-key replay. // replayLegitimateRetryWindow (24h): only a payment created AFTER
// row.CreatedAt+24h can be the sweep's own expired-key replay.
func TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues(t *testing.T) { func TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -1505,7 +1522,7 @@ func TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues(t *testing.T)
} }
// The replayed COMPLETED payment is a legitimate same-key retry created // The replayed COMPLETED payment is a legitimate same-key retry created
// 21.5h after the row — inside the 22h legitimate window, so it is the real // 21.5h after the row — inside the 24h legitimate window, so it is the real
// charge and must be rescued, not refused as an expired-key duplicate. // charge and must be rescued, not refused as an expired-key duplicate.
var rowCreatedAt time.Time var rowCreatedAt time.Time
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
+1 -1
View File
@@ -314,7 +314,7 @@ func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *D
SELECT SELECT
COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
+ COALESCE(SUM(COALESCE(bcs.override_price, cs.price)), 0) + COALESCE(SUM(COALESCE(bcs.override_price, cs.price)), 0)
- COALESCE((SELECT SUM(amount) FROM payments WHERE booking_id = b.id AND status = 'completed'), 0) AS amount_due, - COALESCE((SELECT SUM(amount) FROM payments WHERE booking_id = b.id AND status = 'completed' AND payment_type <> 'tip'), 0) AS amount_due,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 0) COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 0)
+ COALESCE(SUM(COALESCE(bcs.override_duration_minutes, cs.duration_minutes)), 0) AS duration_minutes + COALESCE(SUM(COALESCE(bcs.override_duration_minutes, cs.duration_minutes)), 0) AS duration_minutes
FROM bookings b_inner FROM bookings b_inner
+66 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@@ -14,14 +15,17 @@ import (
"os" "os"
"time" "time"
"crussell/auth"
"crussell/db" "crussell/db"
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/internal/dav" "crussell/internal/dav"
"crussell/internal/s3" "crussell/internal/s3"
"crussell/internal/square" "crussell/internal/square"
"crussell/internal/twofa"
"crussell/mw" "crussell/mw"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
) )
// --- Square erasure retry + alerting + durable outbox (GDPR H3 / A1) --- // --- 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 // DELETE /api/user/account
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
@@ -211,8 +224,10 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
var accountRole string var accountRole string
var profilePicURL sql.NullString var profilePicURL sql.NullString
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID). var passwordHash sql.NullString
Scan(&accountRole, &profilePicURL) 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "user not found", http.StatusNotFound) http.Error(w, "user not found", http.StatusNotFound)
@@ -225,6 +240,48 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() 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) --- // --- External system scrubbing (BEFORE SQL anonymize) ---
// Delete profile picture from S3/R2 // 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). // columns are NULLed, but the cache is never touched by either).
payments.InvalidateSquareCustomerCache(userID) 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 // Build the context used for critical-notification inserts: route through
// the request transaction when one is active (tests) so alerts roll back // the request transaction when one is active (tests) so alerts roll back
// with the fixture; otherwise fall back to the shared pool (production, // with the fixture; otherwise fall back to the shared pool (production,
+63
View File
@@ -245,3 +245,66 @@ func TestAdminSendVerificationCode_UnknownCustomer_NotFound(t *testing.T) {
w := makeAdmin2FARequest(http.HandlerFunc(AdminSendVerificationCodeHandler), http.MethodPost, "/api/admin/users/no-such-user/2fa/code", "admin", ctx) w := makeAdmin2FARequest(http.HandlerFunc(AdminSendVerificationCodeHandler), http.MethodPost, "/api/admin/users/no-such-user/2fa/code", "admin", ctx)
require.Equal(t, http.StatusNotFound, w.Code, "unknown target user must 404") require.Equal(t, http.StatusNotFound, w.Code, "unknown target user must 404")
} }
// TestAdminSendVerificationCode_WritesAuditTrail verifies finding 2a: an
// admin-scoped 2FA mint writes an admin_audit_log row (action_type
// '2fa_code_mint') keyed to the ADMIN, with details recording whether the code
// was FRESH or REUSED and the remaining lifetime. Uses a real admin user so the
// admin_id FK is satisfied (the synthetic "admin-test-id" elsewhere would make
// the best-effort audit write a no-op).
func TestAdminSendVerificationCode_WritesAuditTrail(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
customerID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, customerID)
require.NoError(t, err)
// Fresh mint: audit row must record reused=false + the full 10-minute life.
req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+customerID+"/2fa/code", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", customerID)
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
req = req.WithContext(context.WithValue(req.Context(), mw.UserIDKey, adminID))
w := httptest.NewRecorder()
AdminSendVerificationCodeHandler(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var auditCount int
var actionType string
var targetUserID sql.NullString
var details sql.NullString
err = tx.QueryRow(ctx, `
SELECT COUNT(*), MAX(action_type), MAX(target_user_id), MAX(details::text)
FROM admin_audit_log WHERE admin_id = $1 AND action_type = '2fa_code_mint'
`, adminID).Scan(&auditCount, &actionType, &targetUserID, &details)
require.NoError(t, err)
require.Equal(t, 1, auditCount, "a fresh admin mint must write exactly one audit row")
require.Equal(t, "2fa_code_mint", actionType)
require.True(t, targetUserID.Valid && targetUserID.String == customerID, "audit must target the customer, not the admin")
var freshDetails map[string]any
require.NoError(t, json.Unmarshal([]byte(details.String), &freshDetails), "audit details must be parseable JSON")
require.Equal(t, false, freshDetails["reused"], "a fresh mint must be audited as fresh")
require.Equal(t, float64(600), freshDetails["remaining_seconds"], "a fresh mint reports the full 10-minute lifetime")
// Second request reuses the still-valid code: audit must record reuse. (Both
// rows share the transaction's NOW(), so select by the distinguishing detail
// rather than created_at.)
w2 := httptest.NewRecorder()
AdminSendVerificationCodeHandler(w2, req)
require.Equal(t, http.StatusOK, w2.Code, w2.Body.String())
err = tx.QueryRow(ctx, `
SELECT details::text FROM admin_audit_log
WHERE admin_id = $1 AND action_type = '2fa_code_mint'
AND (details->>'reused')::boolean = true
LIMIT 1
`, adminID).Scan(&details)
require.NoError(t, err)
var reuseDetails map[string]any
require.NoError(t, json.Unmarshal([]byte(details.String), &reuseDetails))
require.Equal(t, true, reuseDetails["reused"], "a reused code must be audited as reused")
}
+6 -2
View File
@@ -628,8 +628,12 @@ func TestDeleteAccount_Scrubs2FA_RetainsNotes(t *testing.T) {
t.Fatalf("failed to set user notes + 2FA: %v", err) t.Fatalf("failed to set user notes + 2FA: %v", err)
} }
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) // Finding 3: an enforced environment + a 2FA-enabled user requires a fresh
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) // one-time code at deletion time. Seed a known pending code (the test runs
// with SQUARE_ENVIRONMENT unset → enforced) and present it.
seedPendingTwoFA(t, ctx, tx, userID, "424242")
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "424242")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req) DeleteAccountHandler(rr, req)
+8
View File
@@ -22,6 +22,7 @@ import (
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
coreauth "crussell/auth"
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/handlers/auth" "crussell/handlers/auth"
@@ -808,6 +809,13 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Finding 5: the DELETE above removed every refresh token the user held, but
// the in-memory family-alive cache would keep their access tokens passing
// verifyFamilyAlive for up to familyAliveCacheTTL. Drop the cached verdicts
// for all of the user's families so bound access tokens die on their next
// verification — the password change kills sessions immediately.
coreauth.InvalidateFamilyAliveByUser(userID)
// B9: the current JTI is now in revoked_jtis and every refresh token for // B9: the current JTI is now in revoked_jtis and every refresh token for
// the user has been deleted — existing sessions must re-authenticate. // the user has been deleted — existing sessions must re-authenticate.
log.Printf("Password changed for user %s - current JTI revoked and all refresh tokens deleted; existing sessions must re-authenticate", userID) log.Printf("Password changed for user %s - current JTI revoked and all refresh tokens deleted; existing sessions must re-authenticate", userID)
+2 -4
View File
@@ -307,8 +307,7 @@ func TestAccount_Delete(t *testing.T) {
token := jwt.GenerateUserToken(userID) token := jwt.GenerateUserToken(userID)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -344,8 +343,7 @@ func TestAccount_DeleteGuest(t *testing.T) {
token := jwt.GenerateUserToken(userID) token := jwt.GenerateUserToken(userID)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
+75 -11
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"log/slog"
"math/big" "math/big"
"net/http" "net/http"
"time" "time"
@@ -109,7 +110,7 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
// twoFAMintThrottled reports whether a fresh 2FA code mint for the user is // twoFAMintThrottled reports whether a fresh 2FA code mint for the user is
// still inside the per-user cooldown window (twoFAMintCooldown): a previous // still inside the per-user cooldown window (twoFAMintCooldown): a previous
// mint within the window throttles the request (429) instead of minting // 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. // cooldown rule cannot drift between the setup and disable-flow call paths.
func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool { func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool {
return !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown 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 // Mint cooldown (B11a): the shared twoFAMintThrottled helper bounds setup
// re-mints — the same guard the disable flow applies via // 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 // failed-attempt counter (B11b), so without this a setup-spam loop could
// mint fresh codes (each invalidating the prior lockout state) and keep a // mint fresh codes (each invalidating the prior lockout state) and keep a
// guessing budget alive indefinitely. // guessing budget alive indefinitely.
@@ -480,7 +481,7 @@ func writeTwoFAEnabled(w http.ResponseWriter) {
// recover after a short wait. // recover after a short wait.
const twoFAMintCooldown = 1 * time.Minute 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 // last disable-flow mint is inside twoFAMintCooldown, so the caller returns 429
// instead of minting another fresh code. // instead of minting another fresh code.
var errTwoFAMintThrottled = errors.New("2FA code mint throttled") var errTwoFAMintThrottled = errors.New("2FA code mint throttled")
@@ -521,7 +522,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
st.Mu.Lock() st.Mu.Lock()
defer st.Mu.Unlock() 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) { if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return return
@@ -548,7 +549,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
// saved-card charge, so every charge returned 400 "Verification code expired — // saved-card charge, so every charge returned 400 "Verification code expired —
// request a new one" with no way to get a new one. // 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 + // reuses a still-valid pending code when one exists and otherwise mints +
// delivers a fresh one via the same build-dependent channel as setup // delivers a fresh one via the same build-dependent channel as setup
// (deliverTwoFACode — [2FA] log in dev/test; pepper- and delivery-channel // (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() st.Mu.Lock()
defer st.Mu.Unlock() 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 err != nil {
if errors.Is(err, errTwoFAMintThrottled) { if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) 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 // AdminSendVerificationCodeHandler mints (or reuses) a 2FA code for a TARGET
// user, not the session user. The saved-card charge gate verifies the code // 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, // 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 // and could never authorize the customer's charge. Delivering keyed to the
// customer preserves the invariant that the customer, not the admin, is the // customer preserves the invariant that the customer, not the admin, is the
// authentication subject for their card. // 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) { func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
targetUserID := chi.URLParam(r, "id") targetUserID := chi.URLParam(r, "id")
if targetUserID == "" { if targetUserID == "" {
@@ -664,7 +707,7 @@ func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
st.Mu.Lock() st.Mu.Lock()
defer st.Mu.Unlock() 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 err != nil {
if errors.Is(err, errTwoFAMintThrottled) { if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) 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 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())} resp := map[string]any{"message": "Code sent", "remaining_seconds": int(remaining.Seconds())}
if !twoFARequired() && code != "" { if !twoFARequired() && code != "" {
// Dev convenience (matches setup): return the freshly minted code so // 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 // 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 // minted — at most one per twoFAMintCooldown — but it cannot grant a fresh
// guessing budget. // 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) { if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return 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) http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return return
case twoFACodeMissingOrExpired: 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) http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return return
} }
@@ -788,13 +841,20 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) 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 // code to verify against, generating + delivering a fresh one via the same
// build-dependent delivery channel as setup (see deliverTwoFACode) when the // build-dependent delivery channel as setup (see deliverTwoFACode) when the
// stored code is missing or expired. purpose labels the delivery for the [2FA] // 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 // log line (e.g. "disable 2FA", "saved-card charge"). The caller must hold the
// user's attempt-state mutex. // 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 // It returns the plaintext code only when a FRESH code was minted and
// delivered (dev/test builds always deliver it; production builds only when // delivered (dev/test builds always deliver it; production builds only when
// the operator opted into log delivery — see twofa_prod.go). When a valid // 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 // 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 // failed delivery does not start the cooldown (the stamp is written only after
// the UPDATE persisted). // 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 pendingHash sql.NullString
var pendingExpires sql.NullTime var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), ` err := db.Conn.QueryRow(r.Context(), `
@@ -828,6 +888,10 @@ func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptStat
return "", 0, err return "", 0, err
} }
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) { 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 return "", pendingExpires.Time.Sub(clock.Now()), nil
} }
now := clock.Now() now := clock.Now()
+19 -6
View File
@@ -825,8 +825,11 @@ func TestTwoFADisable_MintThrottled_BoundsGuessing(t *testing.T) {
require.Contains(t, w.Body.String(), "Wait before requesting a new code.") require.Contains(t, w.Body.String(), "Wait before requesting a new code.")
// Exactly ONE fresh code was minted across all six requests — the loop can // Exactly ONE fresh code was minted across all six requests — the loop can
// no longer reset the attempt budget. // no longer reset the attempt budget. Count the fresh-delivery marker
mints := strings.Count(buf.String(), "disable 2FA") // ("[2FA] code delivery requested") rather than the purpose substring:
// finding 2b added a "[2FA] code reused" log line that also names the
// purpose, so a raw purpose count would over-count.
mints := strings.Count(buf.String(), "[2FA] code delivery requested")
require.Equal(t, 1, mints, "expected exactly 1 fresh-code mint; log:\n%s", buf.String()) require.Equal(t, 1, mints, "expected exactly 1 fresh-code mint; log:\n%s", buf.String())
} }
@@ -1182,7 +1185,7 @@ func TestTwoFADisableCode_MintsFreshCode(t *testing.T) {
} }
// TestTwoFADisableCode_ReusesValidPendingCode verifies that a valid unexpired // TestTwoFADisableCode_ReusesValidPendingCode verifies that a valid unexpired
// pending code is reused by ensurePendingTwoFACode (the stored hash is // pending code is reused by EnsurePendingTwoFACode (the stored hash is
// unchanged) instead of minting a fresh one. // unchanged) instead of minting a fresh one.
func TestTwoFADisableCode_ReusesValidPendingCode(t *testing.T) { func TestTwoFADisableCode_ReusesValidPendingCode(t *testing.T) {
twofaEnvEnforced(t) twofaEnvEnforced(t)
@@ -1203,7 +1206,7 @@ func TestTwoFADisableCode_ReusesValidPendingCode(t *testing.T) {
// TestTwoFADisableCode_MintThrottled verifies the per-user mint cooldown: a // TestTwoFADisableCode_MintThrottled verifies the per-user mint cooldown: a
// second code request inside twoFAMintCooldown returns 429. The pending code is // second code request inside twoFAMintCooldown returns 429. The pending code is
// dropped first (as a lockout does) because a still-valid code is reused by // dropped first (as a lockout does) because a still-valid code is reused by
// ensurePendingTwoFACode, which short-circuits the cooldown check. // EnsurePendingTwoFACode, which short-circuits the cooldown check.
func TestTwoFADisableCode_MintThrottled(t *testing.T) { func TestTwoFADisableCode_MintThrottled(t *testing.T) {
twofaEnvEnforced(t) twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -1290,9 +1293,14 @@ func TestTwoFASendVerificationCode_Enabled_MintsFresh(t *testing.T) {
// TestTwoFASendVerificationCode_ReusesValidPendingCode verifies that a valid // TestTwoFASendVerificationCode_ReusesValidPendingCode verifies that a valid
// unexpired pending code is reused (the stored hash is unchanged) instead of a // unexpired pending code is reused (the stored hash is unchanged) instead of a
// fresh mint, so a mid-flow charge retry is not throttled. // fresh mint, so a mid-flow charge retry is not throttled — and (finding 2b)
// that the reuse leaves an auditable [2FA] log line instead of being silent.
func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) { func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) {
twofaEnvEnforced(t) twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
@@ -1317,6 +1325,11 @@ func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) {
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid) require.True(t, pendingHash.Valid)
require.Equal(t, hashTwoFACode("123456"), pendingHash.String, "existing valid pending code must be reused, not re-minted") require.Equal(t, hashTwoFACode("123456"), pendingHash.String, "existing valid pending code must be reused, not re-minted")
// Finding 2b: reuse must be visible in the [2FA] log stream (no fresh code
// is delivered, so this is the only audit trace of the reuse).
require.Contains(t, buf.String(), "[2FA] code reused", "a reused code must leave a [2FA] log line")
require.NotContains(t, buf.String(), "[2FA] code:", "a reused code must NOT be logged as a fresh delivery")
} }
// TestTwoFASendVerificationCode_NotEnabled_409 verifies that a user who has NOT // TestTwoFASendVerificationCode_NotEnabled_409 verifies that a user who has NOT
@@ -1335,7 +1348,7 @@ func TestTwoFASendVerificationCode_NotEnabled_409(t *testing.T) {
// TestTwoFASendVerificationCode_MintThrottled verifies the per-user mint // TestTwoFASendVerificationCode_MintThrottled verifies the per-user mint
// cooldown applies: a second code request inside twoFAMintCooldown returns 429. // cooldown applies: a second code request inside twoFAMintCooldown returns 429.
// The pending code is dropped first (as a lockout does) because a still-valid // The pending code is dropped first (as a lockout does) because a still-valid
// code is reused by ensurePendingTwoFACode, which short-circuits the cooldown. // code is reused by EnsurePendingTwoFACode, which short-circuits the cooldown.
func TestTwoFASendVerificationCode_MintThrottled(t *testing.T) { func TestTwoFASendVerificationCode_MintThrottled(t *testing.T) {
twofaEnvEnforced(t) twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
+97 -18
View File
@@ -45,6 +45,23 @@ import (
// DeleteAccountHandler Coverage Tests // DeleteAccountHandler Coverage Tests
// ============================================================================= // =============================================================================
// deleteAccountRequest builds the DELETE /api/user/account request the handler
// now requires (finding 3): the current password in the body, plus a 2FA code
// when one is supplied (enforced environments + 2FA-enabled users only).
func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request {
t.Helper()
body := map[string]string{"current_password": password}
if code != "" {
body["verification_code"] = code
}
b, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
return req
}
// TestDeleteAccount_Unauthorized verifies that deleting an account without // TestDeleteAccount_Unauthorized verifies that deleting an account without
// setting user ID in context returns 401 Unauthorized. // setting user ID in context returns 401 Unauthorized.
func TestDeleteAccount_Unauthorized(t *testing.T) { func TestDeleteAccount_Unauthorized(t *testing.T) {
@@ -92,8 +109,7 @@ func TestDeleteAccount_WithBooking(t *testing.T) {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
} }
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req) DeleteAccountHandler(rr, req)
@@ -135,8 +151,7 @@ func TestDeleteAccount_GuestWithBooking(t *testing.T) {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
} }
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req) DeleteAccountHandler(rr, req)
@@ -155,6 +170,77 @@ func TestDeleteAccount_GuestWithBooking(t *testing.T) {
} }
} }
// TestDeleteAccount_WrongPassword_Rejected verifies the finding-3 password
// re-verification: deleting an account with the WRONG current password returns
// 401 and the account survives.
func TestDeleteAccount_WrongPassword_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := deleteAccountRequest(t, ctx, userID, "not-the-password", "")
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusUnauthorized, rr.Code, rr.Body.String())
var firstName string
require.NoError(t, tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName))
require.Equal(t, "Test", firstName, "the account must not be touched by a failed re-verification")
}
// TestDeleteAccount_MissingPasswordBody_Rejected verifies that a DELETE with no
// password body (the pre-finding-3 client contract) is rejected as malformed.
func TestDeleteAccount_MissingPasswordBody_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String())
}
// TestDeleteAccount_Enforced2FA_RequiresCode verifies the finding-3 2FA
// re-verification: in an enforced environment, a 2FA-enabled user must present
// a correct one-time code (and their password) — a wrong code is rejected with
// 400 and the account survives; the correct code deletes it.
func TestDeleteAccount_Enforced2FA_RequiresCode(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// Enforced by default in tests (SQUARE_ENVIRONMENT unset) + 2FA enabled →
// the handler demands a code. A wrong code must 400.
seedPendingTwoFA(t, ctx, tx, userID, "424242")
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "000000")
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code, "wrong 2FA code must be rejected before deletion")
require.Contains(t, rr.Body.String(), "incorrect verification code")
var firstName string
require.NoError(t, tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName))
require.Equal(t, "Test", firstName, "the account must survive a wrong 2FA code")
// A missing code must also be rejected.
req = deleteAccountRequest(t, ctx, userID, "testpassword123", "")
rr = httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code, "a missing 2FA code must be rejected in an enforced environment")
// The correct code (password + code) deletes the account.
req = deleteAccountRequest(t, ctx, userID, "testpassword123", "424242")
rr = httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String())
}
// ============================================================================= // =============================================================================
// ChangePasswordHandler Coverage Tests // ChangePasswordHandler Coverage Tests
// ============================================================================= // =============================================================================
@@ -566,8 +652,7 @@ func TestDeleteAccount_WithProfilePicture(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler) handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest("DELETE", "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
@@ -594,8 +679,7 @@ func TestDeleteAccount_WithSquareClient(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler) handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest("DELETE", "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
@@ -689,8 +773,7 @@ func TestDeleteAccount_LogsRedactCardTokens(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler) handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code) require.Equal(t, http.StatusNoContent, w.Code)
@@ -734,8 +817,7 @@ func TestDeleteAccount_DeletesSquareCustomerOnce(t *testing.T) {
} }
handler := http.HandlerFunc(DeleteAccountHandler) handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code) require.Equal(t, http.StatusNoContent, w.Code)
@@ -813,8 +895,7 @@ func TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure(t *testing.T) {
reqCtx := db.ContextWithTx(context.Background(), &failingTx{Tx: pgxTx, failExec: true}) reqCtx := db.ContextWithTx(context.Background(), &failingTx{Tx: pgxTx, failExec: true})
handler := http.HandlerFunc(DeleteAccountHandler) handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, reqCtx, guestID, "testpassword123", "")
req = req.WithContext(context.WithValue(reqCtx, mw.UserIDKey, guestID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
@@ -875,8 +956,7 @@ func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) {
}) })
handler := http.HandlerFunc(DeleteAccountHandler) handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, context.Background(), userA, "testpassword123", "")
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userA))
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code) require.Equal(t, http.StatusNoContent, w.Code)
@@ -938,8 +1018,7 @@ func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) {
require.NotEmpty(t, originalID, "a Square customer must be provisioned and cached for the save-card user") require.NotEmpty(t, originalID, "a Square customer must be provisioned and cached for the save-card user")
handler := http.HandlerFunc(DeleteAccountHandler) handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code) require.Equal(t, http.StatusNoContent, w.Code)
+61 -25
View File
@@ -11,7 +11,7 @@
// //
// Contract for the payments gate: // Contract for the payments gate:
// //
// err := twofa.VerifyForUser(ctx, userID, code, false) // consume = false // err := twofa.VerifyForUser(ctx, userID, code, twofa.ConsumeOnVerify)
// if err != nil { // if err != nil {
// switch { // switch {
// case errors.Is(err, twofa.ErrIncorrect): // case errors.Is(err, twofa.ErrIncorrect):
@@ -25,18 +25,25 @@
// } // }
// } // }
// //
// The payments saved-card charge gate verifies WITH consume=false (MEDIUM-2): // Consume mode (MEDIUM-2 remediation, finding 1): a successful verify with
// the code is checked at gate time but only NULLed when the charge reaches a // consume=true NULLs the pending code ATOMICALLY in the same critical section
// TERMINAL SUCCESS state (the handlers call twofa.ConsumePendingCode inside the // as the check, so one code authorizes exactly ONE operation — two concurrent
// transaction that records the completed charge). A failed/ambiguous Square // charges can never both pass the gate with the same code (the per-user mutex
// charge therefore does NOT burn the code — the same-key retry re-verifies the // serializes Check, and the second verify reads a NULLed digest and returns
// SAME operator-relayed code instead of hitting a 400 "expired". Consumption is // ErrMissingOrExpired). The payments saved-card CHARGE gates should therefore
// idempotent, so a code still authorizes exactly one completed charge (and // pass twofa.ConsumeOnVerify for FRESH charges: the code is burned at the gate,
// remains bounded by its 10-minute lifetime). The interactive setup/disable // and a failed/ambiguous Square charge re-mints a fresh code (via the user
// flows pass consume=false too — they clear the pending fields themselves on // package's exported EnsurePendingTwoFACode — reached through the HTTP mint
// success (enableTwoFA / disableTwoFA), so the code must stay valid through // endpoints, since handlers/payments cannot import handlers/user) instead of
// their whole handshake. The ONLY remaining consume=true caller is the // re-verifying the same code. This replaces the earlier MEDIUM-2 deferred
// save-card SAVE gate (handlers/payments), where saving a card is itself a // consume (verify-with-consume=false at the gate + ConsumePendingCode at
// terminal success), which under concurrency let two gates both verify the same
// code before either charge consumed it.
//
// The interactive setup/disable flows pass DeferredConsume (false) — they clear
// the pending fields themselves on success (enableTwoFA / disableTwoFA), so the
// code must stay valid through their whole handshake. The save-card SAVE gate
// (handlers/payments) passes ConsumeOnVerify (true), since saving a card is a
// terminal operation with no downstream charge to attach consumption to. // terminal operation with no downstream charge to attach consumption to.
// //
// The failed-attempt counter is keyed per user and resets ONLY on a successful // The failed-attempt counter is keyed per user and resets ONLY on a successful
@@ -66,6 +73,22 @@ import (
// before the pending code is invalidated and a new one must be requested. // before the pending code is invalidated and a new one must be requested.
const MaxAttempts = 5 const MaxAttempts = 5
// Consume mode for VerifyForUser / Check. Named so the magic bool cannot drift
// between call sites (the payments gate vs the interactive flows).
const (
// ConsumeOnVerify makes a successful verify SINGLE-USE immediately: the
// pending-code digest and expiry are NULLed in the same critical section as
// the successful check (see Check). Use this for FRESH terminal operations —
// the saved-card CHARGE gates (finding 1) and the SAVE gate — where one
// code must authorize exactly one operation.
ConsumeOnVerify = true
// DeferredConsume verifies WITHOUT consuming; the caller NULLs the code
// itself when its operation reaches terminal success (ConsumePendingCode) or
// clears the pending fields on success (the interactive enable/disable
// flows).
DeferredConsume = false
)
// AttemptWindow bounds how long a per-user attempt counter lives before // AttemptWindow bounds how long a per-user attempt counter lives before
// resetting, and doubles as the stale-entry eviction horizon for the map. // resetting, and doubles as the stale-entry eviction horizon for the map.
const AttemptWindow = 10 * time.Minute const AttemptWindow = 10 * time.Minute
@@ -287,13 +310,13 @@ const (
// MissingOrExpired. consume makes a correct code single-use IMMEDIATELY: the // MissingOrExpired. consume makes a correct code single-use IMMEDIATELY: the
// stored digest and its expiry are NULLed right here, so one code cannot // stored digest and its expiry are NULLed right here, so one code cannot
// authorize a second operation within its lifetime. The interactive // authorize a second operation within its lifetime. The interactive
// setup/disable flows pass false and clear the pending fields themselves on // setup/disable flows pass DeferredConsume (false) and clear the pending fields
// success. The payments saved-card charge gate now ALSO passes false (MEDIUM-2): // themselves on success. The payments saved-card charge gates pass
// it verifies at gate time and defers consumption to the completed-charge // ConsumeOnVerify (true) for FRESH charges (finding 1): the code is burned at
// transaction via ConsumePendingCode, so a failed Square charge does not burn // the gate, and a failed Square charge re-mints a fresh one. The returned
// the code. The returned error is non-nil only for DB failures (callers return // error is non-nil only for DB failures (callers return 500); a lockout's
// 500); a lockout's pending-code invalidation failure is logged here and still // pending-code invalidation failure is logged here and still reported as a
// reported as a lockout. // lockout.
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) { func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow { if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
st.Count.Store(0) st.Count.Store(0)
@@ -360,6 +383,18 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
st.SetLastActive(clock.Now()) st.SetLastActive(clock.Now())
st.LastMintAt = time.Time{} st.LastMintAt = time.Time{}
ResetAttempts(userID) ResetAttempts(userID)
// LOW 6b: a correct code proves control of the account's second factor, so
// lift any password-guessing login lockout (users.failed_attempts /
// locked_until) — a successful 2FA challenge is a strong auth signal, and
// the only way to reach a 2FA verify is an already-authenticated session.
// Best-effort: a failure only logs; the verify has already succeeded.
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET failed_attempts = 0, locked_until = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to clear login lockout on 2FA verify for user %s: %v", userID, err)
}
if consume { if consume {
// Consume mode (the payments saved-card gate, B6/B10): a verified code // Consume mode (the payments saved-card gate, B6/B10): a verified code
// is single-use. NULL the stored digest and its expiry so the same code // is single-use. NULL the stored digest and its expiry so the same code
@@ -427,11 +462,12 @@ var (
// the payments card-access gate (B6/B10): a saved-card charge must present a // the payments card-access gate (B6/B10): a saved-card charge must present a
// real, freshly-verified challenge. consume makes a correct code single-use // real, freshly-verified challenge. consume makes a correct code single-use
// IMMEDIATELY (the pending-code digest and expiry are NULLed in the same // IMMEDIATELY (the pending-code digest and expiry are NULLed in the same
// critical section as the successful check — see Check). The payments SAVED- // critical section as the successful check — see Check). The payments saved-
// CARD CHARGE gate passes false and consumes later via ConsumePendingCode // card CHARGE gate passes ConsumeOnVerify for FRESH charges (finding 1: a code
// (MEDIUM-2) so a failed charge does not burn the code; the save-card SAVE // authorizes exactly one charge, and a failed charge re-mints); the save-card
// gate and the interactive setup/disable flows pass false and clear the // SAVE gate passes ConsumeOnVerify too; the interactive setup/disable flows
// pending fields themselves on success (enableTwoFA / disableTwoFA). // pass DeferredConsume and clear the pending fields themselves on success
// (enableTwoFA / disableTwoFA).
func VerifyForUser(ctx context.Context, userID, code string, consume bool) error { func VerifyForUser(ctx context.Context, userID, code string, consume bool) error {
st := StateFor(userID) st := StateFor(userID)
st.Mu.Lock() st.Mu.Lock()
+49 -8
View File
@@ -40,8 +40,8 @@ func TestVerifyForUser_CorrectAndWrongCode(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
seedPending(t, ctx, tx, userID, "123456") seedPending(t, ctx, tx, userID, "123456")
require.NoError(t, VerifyForUser(ctx, userID, "123456", false), "correct code must verify") require.NoError(t, VerifyForUser(ctx, userID, "123456", DeferredConsume), "correct code must verify")
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", false), ErrIncorrect) require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", DeferredConsume), ErrIncorrect)
// consume=true (payments saved-card gate path): a success DESTROYS the // consume=true (payments saved-card gate path): a success DESTROYS the
// pending code, so re-verifying the same code reports ErrMissingOrExpired // pending code, so re-verifying the same code reports ErrMissingOrExpired
@@ -49,8 +49,8 @@ func TestVerifyForUser_CorrectAndWrongCode(t *testing.T) {
userID2, err := fixtures.CreateTestUser(tx) userID2, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
seedPending(t, ctx, tx, userID2, "123456") seedPending(t, ctx, tx, userID2, "123456")
require.NoError(t, VerifyForUser(ctx, userID2, "123456", true), "correct code must verify") require.NoError(t, VerifyForUser(ctx, userID2, "123456", ConsumeOnVerify), "correct code must verify")
require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", true), ErrMissingOrExpired, "a consumed code must be single-use") require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", ConsumeOnVerify), ErrMissingOrExpired, "a consumed code must be single-use")
var pendingHash sql.NullString var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID2).Scan(&pendingHash)) require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID2).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "a consumed code must be NULLed in the DB") require.False(t, pendingHash.Valid, "a consumed code must be NULLed in the DB")
@@ -63,16 +63,57 @@ func TestVerifyForUser_LockoutAndMissing(t *testing.T) {
seedPending(t, ctx, tx, userID, "123456") seedPending(t, ctx, tx, userID, "123456")
// Wrong code #1 → ErrIncorrect; four more reach the 5-attempt cap. // Wrong code #1 → ErrIncorrect; four more reach the 5-attempt cap.
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", true), ErrIncorrect) require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", ConsumeOnVerify), ErrIncorrect)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
_ = VerifyForUser(ctx, userID, "999999", true) _ = VerifyForUser(ctx, userID, "999999", ConsumeOnVerify)
} }
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", true), ErrLockedOut) require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", ConsumeOnVerify), ErrLockedOut)
// A fresh user with no pending code → ErrMissingOrExpired. // A fresh user with no pending code → ErrMissingOrExpired.
userID2, err := fixtures.CreateTestUser(tx) userID2, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", true), ErrMissingOrExpired) require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", ConsumeOnVerify), ErrMissingOrExpired)
}
// TestVerifyForUser_ConsumeOnVerifyConcurrency pins the finding-1 contract: a
// code verified with ConsumeOnVerify authorizes exactly ONE operation. Even
// though the per-user mutex serializes the critical section (so no test can
// actually race it), the observable guarantee is that the first verify burns the
// code and any subsequent verify of the same code fails with
// ErrMissingOrExpired — two concurrent charge gates can never both pass.
func TestVerifyForUser_ConsumeOnVerifyConcurrency(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPending(t, ctx, tx, userID, "424242")
// Two "concurrent" charge-gate verifies of the same code, serialized by
// StateFor's per-user mutex exactly as the payments gate would experience
// them. Only the first may succeed.
require.NoError(t, VerifyForUser(ctx, userID, "424242", ConsumeOnVerify), "first charge gate must verify")
require.ErrorIs(t, VerifyForUser(ctx, userID, "424242", ConsumeOnVerify), ErrMissingOrExpired,
"second charge gate with the same code must fail — one code, one charge")
}
// TestVerifyForUser_SuccessClearsLoginLockout pins LOW 6b: a successful 2FA
// verify lifts any password-guessing login lockout (users.failed_attempts /
// locked_until) because a correct code proves control of the second factor.
func TestVerifyForUser_SuccessClearsLoginLockout(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPending(t, ctx, tx, userID, "123456")
_, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 9, locked_until = NOW() + INTERVAL '30 minutes' WHERE id = $1`, userID)
require.NoError(t, err)
require.NoError(t, VerifyForUser(ctx, userID, "123456", ConsumeOnVerify))
var failedAttempts int
var lockedUntil *time.Time
require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil))
require.Zero(t, failedAttempts, "successful 2FA verify must reset the login lockout counter")
require.Nil(t, lockedUntil, "successful 2FA verify must clear locked_until")
} }
// TestConsumePendingCode pins the MEDIUM-2 contract: ConsumePendingCode NULLs // TestConsumePendingCode pins the MEDIUM-2 contract: ConsumePendingCode NULLs
@@ -41,12 +41,15 @@
import { POLICY } from '$lib/constants/policy'; import { POLICY } from '$lib/constants/policy';
import { import {
canSaveCardsForRole, canSaveCardsForRole,
campaignDiscountPence,
depositChargePence,
isNonceStale, isNonceStale,
isOverflowTipConfirmationRequired, isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
submitPaymentWithRetry submitPaymentWithRetry
} from '$lib/square/square'; } from '$lib/square/square';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection'; import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte'; import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { import {
@@ -249,6 +252,13 @@
discounted_total: number; discounted_total: number;
} | null>(null); } | null>(null);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
// =============== Payment Functions =============== // =============== Payment Functions ===============
async function fetchUserDepositsRequired() { async function fetchUserDepositsRequired() {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
@@ -522,12 +532,23 @@
// idempotency key are NOT cleared — the confirm resend is the same // idempotency key are NOT cleared — the confirm resend is the same
// logical charge. // logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) { if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
// The backend's overflow guard compares against the DISCOUNTED
// remaining (remaining + eligible campaign credit), and for a
// deposit it charges req.Amount the campaign credit (the
// frontend sends deposits raw). Both the displayed overflow and
// the amount actually charged must therefore account for the
// eligible campaign discount — mirroring UserPaymentModal so the
// two surfaces can't show different amounts for the same booking.
const depositDiscountPence = campaignDiscountPence(discountPreview);
overflowConfirm = { overflowConfirm = {
amountPence, amountPence,
overflowPence: Math.max( overflowPence: Math.max(
0, 0,
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100) amountPence -
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
), ),
chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount, depositAmount,
body body
}; };
@@ -603,6 +624,10 @@
let overflowConfirm = $state<{ let overflowConfirm = $state<{
amountPence: number; amountPence: number;
overflowPence: number; overflowPence: number;
// Actual amount the backend will charge. Deposits are sent RAW and the
// backend charges amountPence minus the eligible campaign credit, so
// this can differ from amountPence (mirrors UserPaymentModal).
chargePence?: number;
depositAmount: number; depositAmount: number;
body: Record<string, unknown>; body: Record<string, unknown>;
} | null>(null); } | null>(null);
@@ -2573,47 +2598,21 @@
<!-- Pre-start overpayment confirmation: the backend rejected the <!-- Pre-start overpayment confirmation: the backend rejected the
payment because the booking's remaining balance has changed payment because the booking's remaining balance has changed
since it was loaded (stale data). The excess over the since it was loaded (stale data). The excess over the
remaining balance will be recorded as a tip once confirmed. --> remaining balance will be recorded as a tip once confirmed.
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4"> Shared markup with the customer payment modal
<div class="flex items-start gap-2.5"> (OverflowTipConfirm) so the two surfaces can't drift. -->
<svg <OverflowTipConfirm
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" overflowPence={overflowConfirm.overflowPence}
viewBox="0 0 24 24" discountNote={overflowConfirm.chargePence !== undefined &&
fill="none" overflowConfirm.chargePence < overflowConfirm.amountPence
stroke="currentColor" ? `An eligible campaign discount of ${formatCurrency(
stroke-width="2" Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
> )} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
<path d="M12 16v-4M12 8h.01" /> : undefined}
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra £{(
overflowConfirm.overflowPence / 100
).toFixed(2)} will be recorded as a tip. Confirm to continue?
</p>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={isProcessingPayment} loading={isProcessingPayment}
disabled={isProcessingPayment} onConfirm={confirmOverflowPayment}
onclick={confirmOverflowPayment} onCancel={cancelOverflowConfirmation}
> />
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={isProcessingPayment}
onclick={cancelOverflowConfirmation}
>
Cancel
</Button>
</div>
</div>
{:else} {:else}
<BookingSummary <BookingSummary
services={selectedServices} services={selectedServices}
@@ -2698,7 +2697,12 @@
> >
{isProcessingPayment {isProcessingPayment
? 'Processing...' ? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`} : `Pay Deposit ${formatCurrency(
depositChargePence(
Math.round(calculateDepositAmount() * 100),
campaignDiscountPence(discountPreview)
)
)}`}
</Button> </Button>
</div> </div>
@@ -0,0 +1,72 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
interface Props {
overflowPence: number;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
// Optional pre-formatted note describing a discount on the charge (e.g.
// a campaign credit applied to a deposit), shown under the main text.
discountNote?: string;
}
const { overflowPence, onConfirm, onCancel, loading = false, discountNote }: Props = $props();
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
</script>
<!-- Overpayment confirmation: the backend rejected the payment because the
booking's remaining balance has changed since it was loaded (stale data).
The excess over the remaining balance will be recorded as a tip once
confirmed. Shared by the customer payment modal and the booking-flow
deposit step so the two surfaces can't drift on the markup or the
confirm/cancel wiring. -->
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-2.5">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra
{formatCurrency(overflowPence)} will be recorded as a tip. Confirm to continue?
</p>
{#if discountNote}
<p class="mt-2 text-sm font-medium text-amber-800">{discountNote}</p>
{/if}
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={loading}
disabled={loading}
autofocus
onclick={onConfirm}
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={loading}
onclick={onCancel}
>
Cancel
</Button>
</div>
</div>
@@ -90,7 +90,8 @@
// irrelevant to the backend gate, so `enabled` is always true. // irrelevant to the backend gate, so `enabled` is always true.
const twoFactor = useTwoFactorCodeForSavedCard({ const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true, enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled, gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
mint: () => { mint: () => {
const customerID = booking.user_id ?? booking.user?.id; const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode(); return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
@@ -110,7 +111,18 @@
// by /api/admin/today/current-next carries no amount_paid/amount_due/ // by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint // payments, so this is fetched fresh from the admin booking detail endpoint
// on mount and subtracted from the charge (see netTotal). // on mount and subtracted from the charge (see netTotal).
//
// Money finding 1: `amount_paid` (summed over ALL completed payments) can
// include tips — a tip is gratuity, not booking credit, so it must not
// reduce what the customer still owes. The booking detail endpoint computes
// amount_paid in Go over every completed payment (no payment_type filter),
// so the tip-excluded obligation is derived here from the payments list
// rather than trusting amount_paid. This stays consistent whether or not
// the backend starts excluding tips from amount_paid (idempotent either
// way). The tip-INCLUSIVE amount_paid is kept for the "Already paid"
// display (mirrors the customer modal's "Amount Paid" row).
let amountPaidPence = $state(0); let amountPaidPence = $state(0);
let tipExcludedPaidPence = $state(0);
async function fetchAmountPaid() { async function fetchAmountPaid() {
try { try {
const resp = await apiFetch(`/api/admin/bookings/${booking.id}`); const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
@@ -118,20 +130,29 @@
const data = await resp.json(); const data = await resp.json();
if (typeof data.amount_paid === 'number') { if (typeof data.amount_paid === 'number') {
amountPaidPence = Math.round(data.amount_paid * 100); amountPaidPence = Math.round(data.amount_paid * 100);
return;
} }
tipExcludedPaidPence = Math.round(
(data.payments ?? [])
.filter(
(p: { status: string; payment_type: string }) =>
p.status === 'completed' && p.payment_type !== 'tip'
)
.reduce((sum: number, p: { amount: number }) => sum + (p.amount || 0), 0) * 100
);
return;
} }
} catch (_err) { } catch (_err) {
// fall through to the booking prop below // fall through to the booking prop below
} }
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100); amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
tipExcludedPaidPence = amountPaidPence;
} }
const loyaltyEligible = $derived( const loyaltyEligible = $derived(
stamps >= 10 && stamps >= 10 &&
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') && !(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
booking.total_amount > 0 && booking.total_amount > 0 &&
amountPaidPence === 0 tipExcludedPaidPence === 0
); );
const loyaltyDiscount = $derived( const loyaltyDiscount = $derived(
@@ -283,7 +304,10 @@
// remaining value, so the frontend charge and the backend record now agree // remaining value, so the frontend charge and the backend record now agree
// and a prior deposit can no longer land as an unintended tip. // and a prior deposit can no longer land as an unintended tip.
const netTotal = $derived( const netTotal = $derived(
Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence) Math.max(
0,
subtotal - discountSum - campaignDiscountPence(discountPreview) - tipExcludedPaidPence
)
); );
const tipPercentages = $derived.by(() => { const tipPercentages = $derived.by(() => {
@@ -9,6 +9,7 @@
import type { Booking } from '$lib/types/booking'; import type { Booking } from '$lib/types/booking';
import CardSelection from '$lib/components/payments/CardSelection.svelte'; import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte'; import { savedCardsStore } from '$lib/stores/savedCards.svelte';
@@ -17,6 +18,7 @@
import { generateUUID } from '$lib/utils/uuid'; import { generateUUID } from '$lib/utils/uuid';
import { import {
campaignDiscountPence, campaignDiscountPence,
depositChargePence,
isNonceStale, isNonceStale,
isOverflowTipConfirmationRequired, isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired, isSavedCardVerificationRequired,
@@ -723,58 +725,21 @@
<!-- Overpayment confirmation: the backend rejected the payment because <!-- Overpayment confirmation: the backend rejected the payment because
the booking's remaining balance has changed since it was loaded the booking's remaining balance has changed since it was loaded
(stale data). The excess over the remaining balance will be (stale data). The excess over the remaining balance will be
recorded as a tip once confirmed. Applies both before and after recorded as a tip once confirmed. Shared markup with the
the appointment has started (B12). --> booking-flow deposit step (OverflowTipConfirm) so the two surfaces
<div class="rounded-md border border-amber-200 bg-amber-50 p-4"> can't drift. -->
<div class="flex items-start gap-2.5"> <OverflowTipConfirm
<svg overflowPence={overflowConfirm.overflowPence}
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" discountNote={overflowConfirm.paymentType === 'deposit' &&
viewBox="0 0 24 24" overflowConfirm.chargePence !== undefined
fill="none" ? `An eligible campaign discount of ${formatCurrency(
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra
{formatCurrency(overflowConfirm.overflowPence)} will be recorded as a tip. Confirm to
continue?
</p>
{#if overflowConfirm.paymentType === 'deposit' && overflowConfirm.chargePence !== undefined}
<p class="mt-2 text-sm font-medium text-amber-800">
An eligible campaign discount of
{formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence) Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} )} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}. : undefined}
</p>
{/if}
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={status === 'processing'} loading={status === 'processing'}
disabled={status === 'processing'} onConfirm={confirmOverflowPayment}
autofocus onCancel={cancelOverflowConfirmation}
onclick={confirmOverflowPayment} />
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={status === 'processing'}
onclick={cancelOverflowConfirmation}
>
Cancel
</Button>
</div>
</div>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p> <p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<Button <Button
variant="ghost" variant="ghost"
@@ -1049,9 +1014,12 @@
> >
{#if paymentType === 'deposit'} {#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency( Pay Deposit ({formatCurrency(
depositChargePence(
booking.deposit_amount booking.deposit_amount
? Math.round(booking.deposit_amount * 100) ? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100) : Math.round(booking.total_amount * 0.2 * 100),
campaignDiscountPence(discountPreview)
)
)}) )})
{:else} {:else}
Pay {formatCurrency( Pay {formatCurrency(
+74
View File
@@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
NONCE_STALENESS_MS, NONCE_STALENESS_MS,
SAVED_CARD_VERIFICATION_MESSAGE, SAVED_CARD_VERIFICATION_MESSAGE,
adminRequestNewTwoFactorCode,
campaignDiscountPence, campaignDiscountPence,
canSaveCardsForRole, canSaveCardsForRole,
depositChargePence,
isAmbiguousPaymentFailure, isAmbiguousPaymentFailure,
isNonceStale, isNonceStale,
isOverflowTipConfirmationRequired, isOverflowTipConfirmationRequired,
@@ -420,3 +422,75 @@ describe('requestNewTwoFactorCode', () => {
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi'); expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
}); });
}); });
describe('adminRequestNewTwoFactorCode', () => {
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs to the admin customer-scoped mint URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(true);
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
});
it('URL-encodes the customer userID', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
await adminRequestNewTwoFactorCode('usr a/b');
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('/api/admin/users/usr%20a%2Fb/2fa/code');
});
it('surfaces the 429 mint-cooldown error message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429))
);
const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(false);
expect(result.status).toBe(429);
expect(result.message).toContain('Too many requests');
});
it('attaches the Bearer token from localStorage and POSTs', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('localStorage', {
getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null)
});
await adminRequestNewTwoFactorCode('usr_abc');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
});
});
describe('depositChargePence', () => {
it('subtracts the eligible campaign credit when it is smaller than the deposit', () => {
expect(depositChargePence(2000, 500)).toBe(1500);
});
it('charges the full deposit when the credit equals the deposit (A6 clamp-up)', () => {
expect(depositChargePence(2000, 2000)).toBe(2000);
});
it('charges the full deposit when the credit exceeds the deposit (A6 clamp-up)', () => {
expect(depositChargePence(2000, 2500)).toBe(2000);
});
it('is unchanged when no campaign credit applies', () => {
expect(depositChargePence(2000, 0)).toBe(2000);
});
});
+22 -24
View File
@@ -73,6 +73,19 @@ export function campaignDiscountPence(discountPreview: DiscountPreview | null):
: 0; : 0;
} }
/**
* The deposit charge as the backend computes it (A4/A6 in
* backend/handlers/payments/handlers.go): deposits are sent RAW by the
* frontend and charged at `deposit eligible campaign credit`, clamping UP
* to the full deposit when the credit the deposit (the credit then covers
* the residual balance via the discount row). Payment surfaces must display
* this same amount so the customer never sees a higher deposit than the card
* is actually charged.
*/
export function depositChargePence(depositPence: number, discountPence: number): number {
return discountPence >= depositPence ? depositPence : depositPence - discountPence;
}
/** True when a cached card nonce can no longer be reused: it was tokenized for a /** True when a cached card nonce can no longer be reused: it was tokenized for a
* different amount than `amount`, or it is older than NONCE_STALENESS_MS. */ * different amount than `amount`, or it is older than NONCE_STALENESS_MS. */
export function isNonceStale( export function isNonceStale(
@@ -154,14 +167,16 @@ export interface TwoFactorCodeRequestResult {
* `$lib` imports and the pure-logic vitest suite can exercise it without a * `$lib` imports and the pure-logic vitest suite can exercise it without a
* SvelteKit plugin resolving the `$lib` alias. * SvelteKit plugin resolving the `$lib` alias.
*/ */
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> { // Shared 2FA code-mint POST — the session and admin variants differ only in
// the URL, so keeping one body stops the two copies drifting apart.
async function requestTwoFactorCode(path: string): Promise<TwoFactorCodeRequestResult> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
} }
try { try {
const response = await fetch('/api/user/2fa/code', { method: 'POST', headers }); const response = await fetch(path, { method: 'POST', headers });
if (response.ok) { if (response.ok) {
const data = (await response.json().catch(() => null)) as { message?: unknown } | null; const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
const message = const message =
@@ -179,34 +194,17 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
} }
} }
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> {
return requestTwoFactorCode('/api/user/2fa/code');
}
/** Admin-scoped 2FA mint: requests a fresh code FOR the given customer (the /** Admin-scoped 2FA mint: requests a fresh code FOR the given customer (the
* card owner) at the till/admin payment modal. The backend keys the mint to * card owner) at the till/admin payment modal. The backend keys the mint to
* the CUSTOMER's userID, so the code is delivered to the customer and can * the CUSTOMER's userID, so the code is delivered to the customer and can
* satisfy the card-owner gate the admin's session never receives or * satisfy the card-owner gate the admin's session never receives or
* authenticates the customer's card. */ * authenticates the customer's card. */
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> { export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
const headers: Record<string, string> = {}; return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`, { method: 'POST', headers });
if (response.ok) {
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
const message =
typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.';
return { status: response.status, ok: true, message };
}
const body = await response.text();
return {
status: response.status,
ok: false,
message: extractServerErrorMessage(body) || 'Failed to request a new verification code'
};
} catch {
return { status: 0, ok: false, message: 'Network error requesting a new code' };
}
} }
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request /** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
+169 -7
View File
@@ -2,6 +2,19 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
// Cross-tab refresh-token coordination (LOW-MEDIUM 4): every tab shares ONE
// opaque refresh token in localStorage (`authRefreshToken`) and every refresh
// ROTATES it server-side. Without coordination, two tabs refreshing on load
// would both present the same token: the first rotation consumes it, and the
// second replay either 401s that tab (inside the server's 60s reuse grace) or —
// beyond the grace — kills the ENTIRE rotation family with a false theft alert.
// The lock below guarantees only ONE tab performs the rotation; the others
// adopt the rotated pair from the BroadcastChannel broadcast (or, on a
// stale-lock timeout, from localStorage).
const REFRESH_LOCK_KEY = 'authRefreshInProgress';
const REFRESH_LOCK_TTL_MS = 15_000;
const REFRESH_CHANNEL_NAME = 'crussell-auth-refresh';
type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate'; type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
interface DecodedToken { interface DecodedToken {
@@ -40,10 +53,14 @@ class AuthStore {
private refreshToken = $state<string | null>(null); private refreshToken = $state<string | null>(null);
private user = $state<User | null>(null); private user = $state<User | null>(null);
private loading = $state(true); private loading = $state(true);
// Broadcast channel used to announce a completed rotation to sibling tabs
// (finding 4). Null when the platform has no BroadcastChannel support.
private refreshChannel: BroadcastChannel | null = null;
constructor() { constructor() {
if (browser) { if (browser) {
this.initializeAuth(); this.initializeAuth();
this.setupCrossTabRefresh();
// Check token refresh every 2 minutes // Check token refresh every 2 minutes
// (must be shorter than the 5-minute threshold so the // (must be shorter than the 5-minute threshold so the
// refresh check fires before the token actually expires) // refresh check fires before the token actually expires)
@@ -230,6 +247,135 @@ class AuthStore {
} }
} }
// --- Cross-tab refresh coordination (finding 4) ---
// Sets up the two coordination channels for the shared refresh token: a
// `storage` listener (fires in sibling tabs when this tab writes
// localStorage) and a BroadcastChannel for announcing rotations.
private setupCrossTabRefresh() {
window.addEventListener('storage', (e) => {
if (e.key === 'authToken' || e.key === 'authRefreshToken') {
this.adoptFromLocalStorage();
}
});
if (typeof BroadcastChannel !== 'undefined') {
this.refreshChannel = new BroadcastChannel(REFRESH_CHANNEL_NAME);
}
}
// Adopts the shared session's current localStorage state. Only acts when
// this tab is ALREADY authenticated: a rotation keeps the same account, so
// only an existing session adopts a changed token — a logged-out sibling
// tab must NOT silently log in via another tab's login. A cleared
// authToken (logout or failed refresh in a sibling) clears this tab too.
private adoptFromLocalStorage() {
const storedToken = localStorage.getItem('authToken');
if (storedToken && storedToken !== this.token && this.token) {
this.adoptTokens(storedToken, localStorage.getItem('authRefreshToken'));
} else if (!storedToken && this.token) {
this.clearAuth();
}
}
// Swaps in a rotated token pair without re-writing localStorage (that would
// re-trigger sibling storage events pointlessly) and without re-fetching the
// profile (the rotating tab already did; profile data is account-wide).
private adoptTokens(token: string, refreshToken: string | null) {
const decoded = this.decodeToken(token);
if (!decoded) return;
this.token = token;
if (refreshToken) this.refreshToken = refreshToken;
if (!this.user || this.user.id !== decoded.user_id) {
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: '',
twoFactorEnabled: false,
twoFactorRequired: false
};
}
}
// tryAcquireRefreshLock atomically-ish claims the single-rotator lock. A
// fresh lock held by another tab means it is mid-rotation — back off. The
// post-write re-read closes the cross-tab check-then-set race: if two tabs
// set the flag in the same instant, the loser's re-read sees the winner's
// timestamp. A stale lock (crash / suspended tab) is stolen after
// REFRESH_LOCK_TTL_MS.
private tryAcquireRefreshLock(): boolean {
const now = Date.now();
const existing = localStorage.getItem(REFRESH_LOCK_KEY);
if (existing) {
const ts = Number(existing);
if (Number.isFinite(ts) && now - ts < REFRESH_LOCK_TTL_MS) {
return false;
}
}
localStorage.setItem(REFRESH_LOCK_KEY, String(now));
return localStorage.getItem(REFRESH_LOCK_KEY) === String(now);
}
private releaseRefreshLock() {
localStorage.removeItem(REFRESH_LOCK_KEY);
}
private broadcastRefresh(token: string, refreshToken: string | null) {
this.refreshChannel?.postMessage({ type: 'auth-refreshed', token, refreshToken });
}
private broadcastRefreshFailure() {
this.refreshChannel?.postMessage({ type: 'auth-refresh-failed' });
}
// Waits for the sibling tab that holds the rotation lock to finish, then
// adopts the outcome. With BroadcastChannel support it waits for the
// `auth-refreshed` / `auth-refresh-failed` announcement; without it, it
// polls for the lock to clear. Either way it falls through to adopting
// whatever the rotating tab wrote to localStorage (the rotating tab
// persists the pair before broadcasting), so a channel-less sibling still
// converges. A 401 failure in the rotating tab clears auth in this tab too.
private async waitForAnotherTabRefresh(): Promise<void> {
const result = await new Promise<'ok' | 'failed' | 'timeout'>((resolve) => {
if (!this.refreshChannel) {
const deadline = Date.now() + REFRESH_LOCK_TTL_MS;
const poll = setInterval(() => {
if (localStorage.getItem(REFRESH_LOCK_KEY) === null || Date.now() >= deadline) {
clearInterval(poll);
resolve('timeout');
}
}, 250);
return;
}
const onMessage = (ev: MessageEvent) => {
if (!ev.data) return;
if (ev.data.type === 'auth-refreshed') {
cleanup();
resolve('ok');
} else if (ev.data.type === 'auth-refresh-failed') {
cleanup();
resolve('failed');
}
};
const timer = setTimeout(() => {
cleanup();
resolve('timeout');
}, REFRESH_LOCK_TTL_MS + 5_000);
const cleanup = () => {
clearTimeout(timer);
this.refreshChannel?.removeEventListener('message', onMessage);
};
this.refreshChannel.addEventListener('message', onMessage);
});
if (result === 'failed') {
this.clearAuth();
return;
}
this.adoptFromLocalStorage();
}
hasRole(requiredRole: UserRole | UserRole[]): boolean { hasRole(requiredRole: UserRole | UserRole[]): boolean {
if (!this.user) return false; if (!this.user) return false;
@@ -265,15 +411,26 @@ class AuthStore {
// Refresh if token expires in less than 5 minutes // Refresh if token expires in less than 5 minutes
// (1-hour token lifetime from backend) // (1-hour token lifetime from backend)
const fiveMinutes = 5 * 60 * 1000; const fiveMinutes = 5 * 60 * 1000;
if (decoded.exp * 1000 - Date.now() < fiveMinutes) { if (decoded.exp * 1000 - Date.now() >= fiveMinutes) return;
// B5: without the opaque refresh token we cannot refresh — the
// access token is not an accepted credential here. Clear auth // B5: without the opaque refresh token we cannot refresh — the access
// rather than send a guaranteed-401 request. // token is not an accepted credential here. Clear auth rather than send
// a guaranteed-401 request.
if (!this.refreshToken) { if (!this.refreshToken) {
this.clearAuth(); this.clearAuth();
return; return;
} }
// Cross-tab dedup (finding 4): only ONE tab may present the shared
// refresh token to /api/refresh-token — a concurrent sibling would
// replay the just-rotated token and trigger the server's reuse/theft
// handling. If another tab holds the lock, wait for its rotation and
// adopt the result.
if (!this.tryAcquireRefreshLock()) {
await this.waitForAnotherTabRefresh();
return;
}
try { try {
const response = await fetch('/api/refresh-token', { const response = await fetch('/api/refresh-token', {
method: 'POST', method: 'POST',
@@ -287,15 +444,20 @@ class AuthStore {
// B5 contract: `{token, refreshToken}` — every refresh ROTATES // B5 contract: `{token, refreshToken}` — every refresh ROTATES
// both tokens, so store the fresh pair. (The snake_case // both tokens, so store the fresh pair. (The snake_case
// `refresh_token` key is handled for parity with login.) // `refresh_token` key is handled for parity with login.)
this.setToken(data.token, data.refreshToken ?? data.refresh_token ?? null); const newRefreshToken = data.refreshToken ?? data.refresh_token ?? null;
this.setToken(data.token, newRefreshToken);
this.broadcastRefresh(data.token, newRefreshToken);
} else { } else {
// Refresh failed (revoked/expired refresh token → 401, etc.). // Refresh failed (revoked/expired refresh token → 401, etc.).
// Clear auth — never retry with the access token. // Clear auth — never retry with the access token. Tell sibling
// tabs the shared session died so they clear too.
this.clearAuth(); this.clearAuth();
this.broadcastRefreshFailure();
} }
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error('Token refresh failed:', error);
} } finally {
this.releaseRefreshLock();
} }
} }