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:
@@ -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 {
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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" />
|
loading={isProcessingPayment}
|
||||||
</svg>
|
onConfirm={confirmOverflowPayment}
|
||||||
<div>
|
onCancel={cancelOverflowConfirmation}
|
||||||
<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}
|
|
||||||
disabled={isProcessingPayment}
|
|
||||||
onclick={confirmOverflowPayment}
|
|
||||||
>
|
|
||||||
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"
|
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||||
stroke-width="2"
|
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
|
||||||
>
|
: undefined}
|
||||||
<path d="M12 16v-4M12 8h.01" />
|
loading={status === 'processing'}
|
||||||
<circle cx="12" cy="12" r="10" />
|
onConfirm={confirmOverflowPayment}
|
||||||
</svg>
|
onCancel={cancelOverflowConfirmation}
|
||||||
<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)
|
|
||||||
)}
|
|
||||||
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex gap-2">
|
|
||||||
<Button
|
|
||||||
class="flex-1"
|
|
||||||
loading={status === 'processing'}
|
|
||||||
disabled={status === 'processing'}
|
|
||||||
autofocus
|
|
||||||
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(
|
||||||
booking.deposit_amount
|
depositChargePence(
|
||||||
? Math.round(booking.deposit_amount * 100)
|
booking.deposit_amount
|
||||||
: Math.round(booking.total_amount * 0.2 * 100)
|
? Math.round(booking.deposit_amount * 100)
|
||||||
|
: Math.round(booking.total_amount * 0.2 * 100),
|
||||||
|
campaignDiscountPence(discountPreview)
|
||||||
|
)
|
||||||
)})
|
)})
|
||||||
{:else}
|
{:else}
|
||||||
Pay {formatCurrency(
|
Pay {formatCurrency(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,37 +411,53 @@ 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
|
|
||||||
// rather than send a guaranteed-401 request.
|
|
||||||
if (!this.refreshToken) {
|
|
||||||
this.clearAuth();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// B5: without the opaque refresh token we cannot refresh — the access
|
||||||
const response = await fetch('/api/refresh-token', {
|
// token is not an accepted credential here. Clear auth rather than send
|
||||||
method: 'POST',
|
// a guaranteed-401 request.
|
||||||
headers: {
|
if (!this.refreshToken) {
|
||||||
Authorization: `Bearer ${this.refreshToken}`
|
this.clearAuth();
|
||||||
}
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
// Cross-tab dedup (finding 4): only ONE tab may present the shared
|
||||||
const data = await response.json();
|
// refresh token to /api/refresh-token — a concurrent sibling would
|
||||||
// B5 contract: `{token, refreshToken}` — every refresh ROTATES
|
// replay the just-rotated token and trigger the server's reuse/theft
|
||||||
// both tokens, so store the fresh pair. (The snake_case
|
// handling. If another tab holds the lock, wait for its rotation and
|
||||||
// `refresh_token` key is handled for parity with login.)
|
// adopt the result.
|
||||||
this.setToken(data.token, data.refreshToken ?? data.refresh_token ?? null);
|
if (!this.tryAcquireRefreshLock()) {
|
||||||
} else {
|
await this.waitForAnotherTabRefresh();
|
||||||
// Refresh failed (revoked/expired refresh token → 401, etc.).
|
return;
|
||||||
// Clear auth — never retry with the access token.
|
}
|
||||||
this.clearAuth();
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/refresh-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.refreshToken}`
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Token refresh failed:', error);
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// B5 contract: `{token, refreshToken}` — every refresh ROTATES
|
||||||
|
// both tokens, so store the fresh pair. (The snake_case
|
||||||
|
// `refresh_token` key is handled for parity with login.)
|
||||||
|
const newRefreshToken = data.refreshToken ?? data.refresh_token ?? null;
|
||||||
|
this.setToken(data.token, newRefreshToken);
|
||||||
|
this.broadcastRefresh(data.token, newRefreshToken);
|
||||||
|
} else {
|
||||||
|
// Refresh failed (revoked/expired refresh token → 401, etc.).
|
||||||
|
// Clear auth — never retry with the access token. Tell sibling
|
||||||
|
// tabs the shared session died so they clear too.
|
||||||
|
this.clearAuth();
|
||||||
|
this.broadcastRefreshFailure();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed:', error);
|
||||||
|
} finally {
|
||||||
|
this.releaseRefreshLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user