fix: loop-B adversarial findings — tip-type double-charge, tip-refund capacity, loyalty stamp farming, gate ordering, auth amplification, admin audit log

Loop B restart (money/security/dup-mod adversarial) fixes:
- CRITICAL: CreateTerminalPayment rejects payment_type='tip' (mirrors CreateBookingPayment) — a tip-typed admin charge no longer records the FULL amount as a tip and double-collects (all is-paid computations exclude tip rows)
- HIGH: tip refunds can no longer re-open booking capacity — refunded_total subqueries filter payment_type <> 'tip' (service.go) and RefundPayment rejects tip rows
- MEDIUM: loyalty-stamp farming closed — stamp award once-per-booking via loyalty_stamp_awarded_at column (init-script.sql) + existing same-day guard
- MEDIUM: CreateTipPayment/CreateBookingPayment 2FA gates moved AFTER the idempotency completed-dedup (code consumed only on new money paths; terminal path already correct) — lost-response retries return the completed payment instead of 400
- MEDIUM: replayRescueLowerBoundSkew widened to 5m (DB-clock-skew stranded originals now rescued)
- MEDIUM-1: verifyFamilyAlive DB amplification reduced via 30s bounded family-alive cache; admin route group rate-limited
- MEDIUM-3: admin saved-card charges now write admin_audit_log (handlers.go helper + till); [2FA] log line decoupled from user identity
- LOW-1: logout scoped to the presented token's family (no cross-session kill)
- LOW-2: refresh-reuse grace widened for same-IP replays
- LOW-4: squareEnvironmentMismatch enforced for empty env
- LOW-5: uuid.ts hard-fails on Math.random fallback (crypto.randomUUID)
- Cash/giftcard tip-enabled overflow mirrors the card-terminal carve

26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 03d85c6d13
commit 7c424b28b8
23 changed files with 1151 additions and 225 deletions
+152 -6
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"log"
"log/slog"
"strings"
"sync"
"time"
"crussell/clock"
@@ -37,7 +39,17 @@ const refreshTokenLifetimeDays = int64(RefreshTokenLifetime / (24 * time.Hour))
// grace window gets the generic error but does NOT kill the rotation family and
// does NOT raise the refresh_token_reuse alert — only a replay after the window
// has elapsed is treated as theft (see VerifyRefreshToken).
const refreshTokenReuseGrace = 30 * time.Second
//
// LOW-2: 60s (was 30s) because a legitimately-rotated token can be replayed
// from the SAME device well after the old 30s window when the user's second
// tab refreshes on a slower return (e.g. the tab was suspended by the OS and
// wakes up >30s after the first tab rotated). The widened grace costs a stolen
// token up to an extra 30s of freshness before reuse detection fires — an
// acceptable trade-off for not killing a legitimate session. The ideal fix
// (only kill when the replay's IP/User-Agent differs from the rotation's)
// would need the rotation origin persisted per family, which the locked schema
// cannot express today; the widened window is the safe minimum.
const refreshTokenReuseGrace = 60 * time.Second
// refreshTokenReuseGraceSecs is the grace window in whole seconds for the SQL
// make_interval(secs => ...) comparison in VerifyRefreshToken's reuse branch.
@@ -123,6 +135,100 @@ func IsJTIRevoked(ctx context.Context, jti string) bool {
return exists
}
// familyAliveCacheTTL bounds how long a family-alive verdict stays cached.
// VerifyToken already runs one DB query per request for the JTI revocation
// check; the family-alive check would add a second. Caching confirmed verdicts
// for 30s turns that second query into an in-memory lookup for the common case
// (MEDIUM-1), cutting auth-path DB amplification in half. The TTL is short so a
// killed family is re-observed quickly, and the cache is explicitly invalidated
// on every family kill (VerifyRefreshToken reuse branch, LogoutHandler) so
// bound access tokens die immediately when theft is detected (HIGH 1).
const familyAliveCacheTTL = 30 * time.Second
// familyAliveCacheMaxEntries bounds the in-memory map so a flood of distinct
// family ids cannot grow it without bound.
const familyAliveCacheMaxEntries = 10_000
// familyAliveCacheEntry is one cached family-alive verdict. Only DB-CONFIRMED
// results are ever stored — a failed query fails open and is never cached, so a
// transient outage cannot freeze a stale rejection or admission into the cache.
type familyAliveCacheEntry struct {
alive bool
expires time.Time
}
// familyAliveCache is a mutex-guarded, bounded cache of family-alive verdicts
// keyed by "<family_id>|<user_id>" (the family belongs to one user, but the
// composite key keeps the verdict aligned with the SQL conjunct).
var familyAliveCache struct {
mu sync.Mutex
m map[string]familyAliveCacheEntry
}
func init() {
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
}
// familyAliveLookup returns a cached verdict for a family key and whether it is
// still fresh, evicting expired entries opportunistically.
func familyAliveLookup(key string) (alive bool, ok bool) {
familyAliveCache.mu.Lock()
defer familyAliveCache.mu.Unlock()
e, ok := familyAliveCache.m[key]
if !ok {
return false, false
}
if clock.Now().After(e.expires) {
delete(familyAliveCache.m, key)
return false, false
}
return e.alive, true
}
// familyAliveStore records a DB-confirmed verdict, evicting expired entries
// and then the oldest live entry when the cache is at capacity.
func familyAliveStore(key string, alive bool) {
familyAliveCache.mu.Lock()
defer familyAliveCache.mu.Unlock()
now := clock.Now()
e := familyAliveCacheEntry{alive: alive, expires: now.Add(familyAliveCacheTTL)}
if len(familyAliveCache.m) >= familyAliveCacheMaxEntries {
for k, ce := range familyAliveCache.m {
if now.After(ce.expires) {
delete(familyAliveCache.m, k)
}
}
}
if len(familyAliveCache.m) >= familyAliveCacheMaxEntries {
var oldestKey string
var oldestAt time.Time
for k, ce := range familyAliveCache.m {
if oldestKey == "" || ce.expires.Before(oldestAt) {
oldestKey, oldestAt = k, ce.expires
}
}
delete(familyAliveCache.m, oldestKey)
}
familyAliveCache.m[key] = e
}
// InvalidateFamilyAlive drops every cached verdict for a family so the next
// VerifyToken re-queries the DB. Called whenever a rotation family is deleted
// (refresh-token reuse kill, logout) so bound access tokens die on their next
// verification instead of riding the cache TTL (HIGH 1).
func InvalidateFamilyAlive(familyID string) {
if familyID == "" {
return
}
familyAliveCache.mu.Lock()
defer familyAliveCache.mu.Unlock()
for k := range familyAliveCache.m {
if strings.HasPrefix(k, familyID+"|") {
delete(familyAliveCache.m, k)
}
}
}
// CleanupRevokedJTIs removes expired entries from PostgreSQL and returns the count of deleted rows.
func CleanupRevokedJTIs(ctx context.Context) (int, error) {
if db.Conn == nil {
@@ -247,10 +353,14 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
// verifyFamilyAlive rejects access tokens bound to a rotation family that no
// longer exists in refresh_tokens. A token WITHOUT a family_id claim is unbound
// (minted via GenerateToken — tests/legacy callers) and passes. Fails closed on
// a live-DB query error: when the family cannot be confirmed alive the safe
// default for money endpoints is to refuse. Mirrors IsJTIRevoked's nil-db
// availability default (startup/unit tests).
// (minted via GenerateToken — tests/legacy callers) and passes. DB-confirmed
// verdicts are cached for familyAliveCacheTTL (MEDIUM-1) so VerifyToken does
// not run a second query per request; the cache is invalidated on family kills
// so a killed family's access tokens die on their next verification (HIGH 1).
// On a live-DB query error the check FAILS OPEN with a WARN log — matching
// IsJTIRevoked — because genuine theft is already handled by the family kill in
// VerifyRefreshToken's reuse branch, and a transient DB error must not turn
// into a total 401 outage for every authenticated request.
func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string) error {
var familyVal any
if err := token.Get(accessTokenFamilyClaim, &familyVal); err != nil {
@@ -263,20 +373,51 @@ func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string)
if db.Conn == nil {
return nil
}
key := familyID + "|" + userID
if alive, cached := familyAliveLookup(key); cached {
if !alive {
return fmt.Errorf("token revoked")
}
return nil
}
var exists bool
err := db.Conn.QueryRow(ctx,
`SELECT EXISTS(
SELECT 1 FROM refresh_tokens WHERE family_id = $1 AND user_id = $2
)`, familyID, userID).Scan(&exists)
if err != nil {
return fmt.Errorf("token revoked")
slog.Warn("family-alive check failed — failing open (access token admitted)", "family_id", familyID, "err", err)
return nil
}
familyAliveStore(key, exists)
if !exists {
return fmt.Errorf("token revoked")
}
return nil
}
// FamilyIDFromToken returns the access token's family_id claim, or "" when the
// token carries none (unbound — test/legacy minting via GenerateToken). It only
// DECODES the token without re-verifying the signature; callers must only use
// it on a token that already passed VerifyToken (e.g. RequireAuth middleware).
// Used by LogoutHandler to scope refresh-token revocation to the presented
// session's rotation family (LOW-1).
func FamilyIDFromToken(tokenString string) string {
if TokenAuth == nil || tokenString == "" {
return ""
}
decoded, err := TokenAuth.Decode(tokenString)
if err != nil {
return ""
}
var fv any
if err := decoded.Get(accessTokenFamilyClaim, &fv); err != nil {
return ""
}
familyID, _ := fv.(string)
return familyID
}
// jwtClaimGetter is the minimal subset of jwt.Token needed to read a claim
// (the token returned by jwtauth.VerifyToken). Kept as an interface so the
// lestrrat-go/jwx dependency stays out of this file's imports.
@@ -436,6 +577,11 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
// (i) Revoke the ENTIRE family — the reused token and every descendant.
if _, err := tx.Exec(ctx, `DELETE FROM refresh_tokens WHERE family_id = $1`, reusedFamilyID); err != nil {
slog.Error("CRITICAL: refresh token reuse detected but family revocation failed", "userID", reusedUserID, "familyID", reusedFamilyID, "err", err)
} else {
// The family is gone — drop its cached verdict so bound access
// tokens die on their next verification instead of riding the
// family-alive cache TTL (HIGH 1).
InvalidateFamilyAlive(reusedFamilyID)
}
// (ii) Surface the theft in the admin notification centre. The NOT
// EXISTS guard keeps ONE alert per reused family until an admin
+6 -6
View File
@@ -519,13 +519,13 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
}
// MEDIUM 4 grace window: backdate the original's used_at past the 30s
// reuse grace so the replay below is genuine theft. WITHOUT this, a replay
// Reuse grace window: backdate the original's used_at past the 60s reuse
// grace so the replay below is genuine theft. WITHOUT this, a replay
// moments after rotation is a benign two-tab concurrent refresh and the
// family must NOT be killed.
if _, err := tx.Exec(ctx, `
UPDATE refresh_tokens
SET used_at = NOW() - make_interval(secs => 60)
SET used_at = NOW() - make_interval(secs => 120)
WHERE token_hash = encode(sha256($1::bytea), 'hex')
`, original); err != nil {
t.Fatalf("failed to backdate used_at for reuse test: %v", err)
@@ -572,8 +572,8 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
}
}
// TestVerifyRefreshToken_ReplayWithinGrace_IsBenign verifies the MEDIUM 4
// hardening: a used-token replay WITHIN the 30s grace window (two tabs sharing
// TestVerifyRefreshToken_ReplayWithinGrace_IsBenign verifies the reuse
// hardening: a used-token replay WITHIN the 60s grace window (two tabs sharing
// one localStorage refresh token both refreshing on load) is a benign
// concurrent refresh — the generic error is returned, but the rotation family
// survives and no refresh_token_reuse alert is raised.
@@ -679,7 +679,7 @@ func TestAccessTokenKilledWithRotationFamily(t *testing.T) {
// detected, whole family DELETEd.
if _, err := tx.Exec(ctx, `
UPDATE refresh_tokens
SET used_at = NOW() - make_interval(secs => 60)
SET used_at = NOW() - make_interval(secs => 120)
WHERE token_hash = encode(sha256($1::bytea), 'hex')
`, original); err != nil {
t.Fatalf("failed to backdate used_at: %v", err)
+104
View File
@@ -1414,6 +1414,110 @@ func TestLogoutHandler_InvalidToken(t *testing.T) {
}
}
// TestLogoutHandler_RevokesPresentedFamilyOnly pins the LOW-1 fix: logout must
// revoke the refresh tokens of the PRESENTED access token's rotation family
// (family_id claim), NOT every refresh token the user holds on other devices —
// a stolen access token must not be able to wipe all sessions. A second,
// unrelated rotation family for the same user survives the logout.
func TestLogoutHandler_RevokesPresentedFamilyOnly(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// Two independent rotation families for the same user: A (the one being
// logged out) and B (a session on another device that must survive).
_, familyA, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate refresh token A: %v", err)
}
_, familyB, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate refresh token B: %v", err)
}
if familyA == familyB {
t.Fatal("expected two distinct rotation families")
}
// The presented access token is bound to family A (as LoginHandler and
// RefreshTokenHandler mint it).
token, jti, err := auth.GenerateTokenForFamily(userID, "verified_email", familyA)
if err != nil {
t.Fatalf("failed to generate family-bound access token: %v", err)
}
req := httptest.NewRequest("POST", "/api/logout", nil)
req.Header.Set("Authorization", "Bearer "+token)
reqCtx := context.WithValue(ctx, mw.JTIKey, jti)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("logout failed: %d. body: %s", w.Code, w.Body.String())
}
countFamily := func(familyID string) int {
var n int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&n); err != nil {
t.Fatalf("failed to count family rows: %v", err)
}
return n
}
if got := countFamily(familyA); got != 0 {
t.Errorf("expected family A (presented token) to be revoked after logout, got %d rows", got)
}
if got := countFamily(familyB); got != 1 {
t.Errorf("expected family B (other device session) to survive logout, got %d rows", got)
}
}
// TestLogoutHandler_UnboundToken_RevokesUserWide pins the LOW-1 fallback: an
// access token WITHOUT a family_id claim (test/legacy minting via
// GenerateToken) cannot be scoped, so logout falls back to the historical
// user-wide refresh-token delete.
func TestLogoutHandler_UnboundToken_RevokesUserWide(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
if _, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email"); err != nil {
t.Fatalf("failed to generate refresh token: %v", err)
}
token, jti, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate unbound access token: %v", err)
}
req := httptest.NewRequest("POST", "/api/logout", nil)
req.Header.Set("Authorization", "Bearer "+token)
reqCtx := context.WithValue(ctx, mw.JTIKey, jti)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("logout failed: %d. body: %s", w.Code, w.Body.String())
}
var n int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1`, userID).Scan(&n); err != nil {
t.Fatalf("failed to count refresh tokens: %v", err)
}
if n != 0 {
t.Errorf("expected all refresh tokens revoked for an unbound token, got %d rows", n)
}
}
// =============================================================================
// Refresh Token JTI Tests
// =============================================================================
+22 -5
View File
@@ -576,11 +576,28 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
return
}
// B5: logging out must also kill every outstanding refresh token for this
// user, or a previously-issued (possibly stolen) refresh token would keep
// the session alive past logout. The 90-day credential is deleted from
// refresh_tokens, so no refresh request after logout can succeed.
if userID != "" {
// B5: logging out must also kill the outstanding refresh credential for
// THIS session, or a previously-issued (possibly stolen) refresh token
// would keep the session alive past logout.
//
// LOW-1: the revocation is scoped to the PRESENTED access token's rotation
// family (family_id claim) instead of every refresh token the user holds.
// A stolen access token can no longer wipe every session the user keeps on
// other devices — only this token's own lineage dies, which is all B5
// needs (the presented refresh token lives in that family). An unbound
// token (no family_id claim — test/legacy minting via GenerateToken)
// falls back to the user-wide delete, preserving the old behaviour for
// those tokens.
familyID := auth.FamilyIDFromToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if familyID != "" {
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE family_id = $1`, familyID); err != nil {
slog.Error("logout: failed to revoke refresh token family", "familyID", familyID, "err", err)
}
// Drop the family-alive cache verdict so this family's access tokens
// (the logged-out one and any in-flight duplicates) are re-checked
// against the now-empty refresh_tokens on their next request.
auth.InvalidateFamilyAlive(familyID)
} else if userID != "" {
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE user_id = $1`, userID); err != nil {
slog.Error("logout: failed to revoke refresh tokens", "userID", userID, "err", err)
}
+22
View File
@@ -80,6 +80,16 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
var newStampCount int
if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
// Loop B MEDIUM (stamp farming via refund + re-charge): the stamp must
// be awarded at most ONCE per booking, no matter how many times the
// booking is re-completed. A refund never moves the booking out of
// 'in_progress', so a re-payment re-completes it — without this guard
// each in_progress→completed transition would re-award a stamp with no
// net merchant cash flow. The bookings.loyalty_stamp_awarded_at marker
// blocks a booking that already earned its stamp; the marker is written
// (same tx) only when the award actually landed, so a daily-cap-blocked
// completion does not permanently forfeit the booking's stamp. The
// existing "no OTHER completed booking within a day" cap is kept.
if err := tx.QueryRow(ctx, `
UPDATE users
SET loyalty_stamps = loyalty_stamps + 1
@@ -91,6 +101,10 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
AND b.id != $2
)
AND NOT EXISTS (
SELECT 1 FROM bookings b
WHERE b.id = $2 AND b.loyalty_stamp_awarded_at IS NOT NULL
)
RETURNING loyalty_stamps
`, userID, bookingID).Scan(&newStampCount); err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
@@ -98,6 +112,14 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
}
}
}
if newStampCount > 0 {
if _, err := tx.Exec(ctx, `
UPDATE bookings SET loyalty_stamp_awarded_at = NOW()
WHERE id = $1 AND loyalty_stamp_awarded_at IS NULL
`, bookingID); err != nil {
log.Printf("Failed to mark loyalty stamp awarded for booking %s: %v", bookingID, err)
}
}
// Create pending redemption when stamps reach LoyaltyStampCost
if newStampCount == LoyaltyStampCost {
+19 -1
View File
@@ -18,6 +18,7 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/internal/twofa"
"crussell/internal/validators"
"crussell/mw"
@@ -1468,8 +1469,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// and SAVING a new card during this purchase (SaveCard), mirroring
// CreateBookingPayment/CreateTipPayment. A one-off new-card (nonce) charge
// that is not saved is not gated.
// consume=false (MEDIUM-2): the code is verified here but only NULLed
// inside the completed-charge transaction below (ConsumePendingCode), so a
// failed/ambiguous Square charge does NOT burn the operator-relayed code
// and a same-key retry can re-verify the SAME code.
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode) {
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode, false) {
return
}
}
@@ -1720,6 +1725,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
// MEDIUM-2: a saved-card gift-card purchase reached its terminal SUCCESS
// state — consume the verified 2FA code now, inside the transaction that
// records the completed charge (the gate verified without consuming, so a
// failed/ambiguous Square charge did not burn the code and a same-key
// retry could re-verify the SAME code).
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
if consErr := twofa.ConsumePendingCode(ctx, issueTx, userID); consErr != nil {
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but consuming the 2FA code for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, userID, consErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
var cardID string
expiryMonths, err := GetGiftCardExpiryMonths(ctx, issueTx)
+279 -28
View File
@@ -5,6 +5,7 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/internal/twofa"
"crussell/internal/validators"
"crussell/mw"
"crypto/rand"
@@ -25,6 +26,45 @@ import (
"github.com/jackc/pgx/v5"
)
// insertAdminAuditCharge records an admin-initiated saved-card charge in
// admin_audit_log (MEDIUM-3a). Mirrors the balance_check audit in
// giftcards.go:1239-1243 — 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 or a completed charge.
func insertAdminAuditCharge(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)
}
}
type CreateTerminalPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
PaymentType string `json:"payment_type" validate:"required"`
@@ -365,6 +405,23 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return
}
// A3 (mirror of CreateBookingPayment at handlers.go:1596): tips have a
// dedicated endpoint (POST /api/bookings/{id}/tip, CreateTipPayment) which
// enforces the M4 "tips only after the service starts" gate, and the
// tip-enabled terminal overflow carve (B3, tip_enabled) records explicit
// gratuity as its own payment_type='tip' row. A bare payment_type='tip'
// here would record the ENTIRE charge as a tip — and every "is paid"
// computation excludes tip rows (paid_total, GetBookingPaymentInfo,
// bookingIsFullyPaid, GetBookingRefundableAmountPence) — so the booking
// would never be credited and a later legitimate charge would double-collect.
// Reject it BEFORE any charge-path branch (cash/giftcard, saved_card,
// terminal checkout) so all four sub-paths are closed at once.
if req.PaymentType == "tip" {
log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID)
http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", http.StatusBadRequest)
return
}
service := NewPaymentService()
amount := req.Amount
@@ -430,11 +487,29 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return
}
// B3: clamp the recorded amount to the booking's remaining obligation.
// The booking row FOR UPDATE lock above serializes concurrent cash/
// giftcard payments on this booking, so this read races no same-method
// payment. A fully-paid booking is rejected below (nothing left to
// record).
// B3: clamp the recorded amount to the booking's remaining obligation
// unless the customer explicitly requested a tip (tip_enabled) — mirror
// the card-terminal tip bound below: the booking portion can never
// exceed what is owed, and the tip portion can never exceed
// maxTerminalTipPence. The booking row FOR UPDATE lock above serializes
// concurrent cash/giftcard payments on this booking, so this read races
// no same-method payment. A fully-paid no-tip booking is rejected below
// (nothing left to record).
var remaining int64
if req.TipEnabled {
remainingPence, remErr := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if remErr != nil {
log.Printf("Failed to compute remaining balance for tip-enabled terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, remErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
remaining = remainingPence
maxChargePence := remainingPence + maxTerminalTipPence
if amount > maxChargePence {
log.Printf("Terminal %s payment for booking %s clamped from %d to %d pence (remaining obligation %d + max tip bound £%.2f) — the requested total exceeded the booking remainder plus the tip cap", *req.PaymentMethod, bookingID, amount, maxChargePence, remainingPence, float64(maxTerminalTipPence)/100.0)
amount = maxChargePence
}
} else {
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil {
log.Printf("Failed to compute remaining balance for terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, cErr)
@@ -454,23 +529,69 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
log.Printf("Terminal %s payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", *req.PaymentMethod, bookingID, amount, effectiveAmount)
amount = effectiveAmount
}
}
// M4 (mirror of the card-terminal carve at sweep.go): when the customer
// explicitly requested a tip, any part of the charged amount beyond the
// remaining booking value is gratuity and must be recorded as its own
// payment_type='tip' row — never absorbed into the booking payment
// (which would over-credit the booking) nor rejected. The booking
// portion keeps the requested payment type, exactly as the non-tip
// cash/giftcard flow records it.
tipPortion := int64(0)
bookingPortion := amount
if req.TipEnabled && remaining < amount {
tipPortion = amount - remaining
bookingPortion = remaining
}
amountPounds := float64(amount) / 100.0
bookingPortionPounds := float64(bookingPortion) / 100.0
tipPounds := float64(tipPortion) / 100.0
var paymentID string
if *req.PaymentMethod == "cash" {
if bookingPortionPounds > 0.004 {
err = tx.QueryRow(r.Context(), `
INSERT INTO payments (
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at
) VALUES ($1, $2, 'cash', 'completed', $3, $4, $5, NOW(), NOW())
RETURNING id
`, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID).Scan(&paymentID)
`, bookingID, req.PaymentType, bookingPortionPounds, idempotencyKey, adminID).Scan(&paymentID)
if err != nil {
log.Printf("Failed to create cash payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
}
// The tip carve is recorded as its own 'tip' row so the booking
// portion is the only money that counts toward the obligation.
// When the charge is tip-only (fully-paid booking), the tip row is
// the ONLY record and its id is returned as the checkout id,
// mirroring the card-terminal carve (primary := records[0]).
if tipPounds > 0.004 {
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "cash",
Status: "completed",
Amount: tipPounds,
IdempotencyKey: &tipKey,
CreatedBy: &adminID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
}, nil)
if tipErr != nil {
log.Printf("Failed to create cash tip payment record: %v", tipErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if paymentID == "" {
paymentID = tipID
}
}
} else { // giftcard
var customerID sql.NullString
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
@@ -567,29 +688,63 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
giftCardPaymentID = &cleanCardID
}
// The gift-card source funds the full charged amount (booking
// portion + tip). The primary row records the booking portion; the
// tip carve is recorded as its own 'tip' row sourced from the same
// gift card (C3 source-of-funds tracking), so the booking portion
// is the only money that counts toward the obligation.
bookingPayID := ""
if bookingPortionPounds > 0.004 {
err = tx.QueryRow(r.Context(), `
INSERT INTO payments (
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id
) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, $5, NOW(), NOW(), $6)
RETURNING id
`, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID, giftCardPaymentID).Scan(&paymentID)
`, bookingID, req.PaymentType, bookingPortionPounds, idempotencyKey, adminID, giftCardPaymentID).Scan(&bookingPayID)
if err != nil {
log.Printf("Failed to create giftcard payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
paymentID = bookingPayID
}
if tipPounds > 0.004 {
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "giftcard",
Status: "completed",
Amount: tipPounds,
IdempotencyKey: &tipKey,
CreatedBy: &adminID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
}, giftCardPaymentID)
if tipErr != nil {
log.Printf("Failed to create giftcard tip payment record: %v", tipErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if paymentID == "" {
paymentID = tipID
}
}
// Apply VAT at redemption only if the gift card was purchased as MPV
// (VAT deferred to redemption). For SPV, VAT was already paid at sale.
// For account balance payments (usedBalance=true), VAT was already paid
// when the original card was purchased.
// when the original card was purchased. Applied to the booking-portion
// payment only — a tip record is never VAT-applicable.
if bookingPayID != "" {
if usedBalance {
// VAT already paid at purchase time — nothing to do here.
} else if cardVoucherType == "MPV" {
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
if vatErr == nil && vatCfg.IsVATRegistered {
if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
log.Printf("Failed to apply VAT to giftcard payment %s: %v", paymentID, vatExecErr)
if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", bookingPayID, vatCfg.DefaultVATRate); vatExecErr != nil {
log.Printf("Failed to apply VAT to giftcard payment %s: %v", bookingPayID, vatExecErr)
}
}
}
}
@@ -794,7 +949,11 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// existing result WITHOUT demanding a fresh code — no new money moves,
// so no new authorization is needed. Pending-reuse retries and fresh
// charges still pass through the gate.
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode) {
// consume=false (MEDIUM-2): the code is verified here but only NULLed
// inside the completed-charge transaction below (ConsumePendingCode),
// so a failed/ambiguous Square charge does NOT burn the operator-relayed
// code and a same-key retry can re-verify the SAME code.
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, false) {
return
}
@@ -993,6 +1152,19 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// MEDIUM-2: the charge reached its terminal SUCCESS state — consume the
// verified 2FA code now, INSIDE the transaction that records the
// completed charge (the gate verified without consuming so a failed
// charge would not burn the code). A failure here fails the whole
// transaction (the row stays pending and the sweep reconciles), which is
// the same known failure mode as any other post-charge tx error.
if bookingUserID.Valid {
if consErr := twofa.ConsumePendingCode(r.Context(), recheckTx, bookingUserID.String); consErr != nil {
log.Printf("CRITICAL: Square payment %s succeeded but consuming the 2FA code for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, bookingUserID.String, consErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
// B14: apply VAT to the saved-card terminal charge, inside the same
// transaction as the completed flip (like the booking path at 2021-2028
// and the cash path at 397). Without this the saved-card branch never
@@ -1008,6 +1180,20 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return
}
// MEDIUM-3a: record the admin-initiated saved-card charge in
// admin_audit_log (mirroring giftcards.go's balance_check audit). Runs
// best-effort AFTER the money transaction commits so an audit-write
// failure can never roll back a completed charge.
if bookingUserID.Valid {
insertAdminAuditCharge(r.Context(), adminID, bookingUserID.String, "saved_card_charge", map[string]any{
"booking_id": bookingID,
"payment_id": paymentID,
"amount": float64(amount) / 100.0,
"card_last4": paymentResult.CardLast4,
"square_payment_id": paymentResult.SquarePayID,
})
}
// F6: a fully-paid saved-card charge completes the booking exactly like
// the terminal path (recordTerminalPaymentTx → completeFullyPaidBooking,
// sweep.go:1632). Runs in its OWN transaction after the status commit
@@ -1563,11 +1749,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
service := NewPaymentService()
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
return
}
// Resolve buyer email for Square receipt delivery (failure is non-fatal).
var bookingBuyerEmail string
if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&bookingBuyerEmail); err != nil {
@@ -1872,6 +2053,18 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to check idempotency: %v", err)
}
// 2FA gating (C5): persisting a card requires 2FA when the feature is
// enforced. This runs AFTER the idempotency dedup's completed
// short-circuit (Loop B MEDIUM): a same-key lost-response retry returns the
// already-completed payment above without re-entering the gate, so its
// single-use code (already consumed by the original attempt) is never
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
// new charge may move at Square. The gate also runs before
// resolveChargeSource below, so an un-2FA'd request never persists a card.
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
return
}
// After the idempotency check (which handles same-key retries), verify
// that no completed payment of the same non-partial type already exists.
// buildSplitRecords converts 'full' and 'deposit' input types into a
@@ -2005,9 +2198,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
var savedCardID *string
var savedCardCustomerID string
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
// enforced. New-card (nonce) charges are not gated.
// enforced. New-card (nonce) charges are not gated. consume=false
// (MEDIUM-2): the code is verified here but only NULLed inside the
// completed-charge transaction below (ConsumePendingCode), so a
// failed/ambiguous Square charge does NOT burn the operator-relayed code
// and a same-key retry can re-verify the SAME code.
if req.CardID != nil && *req.CardID != "" {
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, false) {
return
}
}
@@ -2268,6 +2465,21 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
// MEDIUM-2: a saved-card charge reached its terminal SUCCESS state —
// consume the verified 2FA code now, inside the same transaction that
// records the completed charge (the gate verified without consuming, so a
// failed charge would not burn the code and a same-key retry could reuse
// it). 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 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",
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
// Insert the additional split records. They carry the derived -split-N
// idempotency keys, which are new rows; if the split produced only one
// record, there is nothing more to insert.
@@ -2825,8 +3037,10 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
// requires 2FA when the feature is enforced — the same gate the booking
// and tip flows apply to req.SaveCard. Persisting a stored credential is
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
// endpoint must not be the un-gated side door.
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
// endpoint must not be the un-gated side door. consume=true: saving a card
// is a terminal operation with no downstream charge to attach consumption
// to (MEDIUM-2).
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
return
}
@@ -2962,6 +3176,21 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
return
}
// A tip payment is gratuity, not booking money: every refund computation
// excludes tip rows (GetBookingRefundableAmountPence, refunds.go, and the
// AdminRefundBooking query at handlers.go:3597). Refunding a tip here would
// pay the gratuity back while GetBookingRemainingBalancePence's refunded
// total re-opens booking charge capacity (a tip refund counts as "returned
// money") — a fully-paid booking would accept a second legitimate charge,
// double-collecting the balance. Tips are deliberately not refundable via
// this handler. Tip split rows share the charge's square_payment_id, so the
// Square reference guard below cannot catch them — this explicit check must
// run before it.
if payment.PaymentType == "tip" {
http.Error(w, "Cannot refund a tip payment", http.StatusBadRequest)
return
}
// Money-safety guard (M7): a payments row with NO booking is a gift-card
// purchase (BuyGiftCard inserts without a booking — the same discriminator
// the sweep uses in sweep.go). Refunding such a payment at Square returns
@@ -3910,7 +4139,11 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
service := NewPaymentService()
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
// consume=true: saving a card is a terminal operation (the card row is
// created right here), so the verified code is single-use immediately —
// unlike the saved-card CHARGE gate below, which defers consumption to the
// charge's terminal success (MEDIUM-2).
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
return
}
@@ -4008,13 +4241,6 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
var sourceID string
var savedCardID *string
var savedCardCustomerID string
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
// enforced. New-card (nonce) charges are not gated.
if req.CardID != nil && *req.CardID != "" {
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
return
}
}
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found")
if !sourceOK {
return
@@ -4144,6 +4370,19 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to check tip idempotency: %v", err)
}
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
// enforced. New-card (nonce) charges are not gated. This runs AFTER the
// idempotency dedup's completed short-circuit (Loop B MEDIUM): a same-key
// lost-response retry returns the already-completed payment above without
// re-entering the gate, so its single-use code (already consumed by the
// original attempt) is never re-rejected as "expired". Pending-reuse and
// fresh paths still gate — a new charge may move at Square.
if req.CardID != nil && *req.CardID != "" {
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, false) {
return
}
}
if !reusePendingRecord {
record := PaymentRecord{
BookingID: bookingID,
@@ -4314,6 +4553,18 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// MEDIUM-2: a saved-card tip charge reached its terminal SUCCESS state —
// consume the verified 2FA code now, inside the transaction that records
// the completed charge (the gate verified without consuming, so a failed
// charge did not burn the code and a same-key retry could reuse it).
if req.CardID != nil && *req.CardID != "" {
if consErr := twofa.ConsumePendingCode(r.Context(), recheckTx, userID); consErr != nil {
log.Printf("CRITICAL: Square tip payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
if cErr := recheckTx.Commit(r.Context()); cErr != nil {
log.Printf("CRITICAL: Square tip payment %s (ID=%s) succeeded but committing the post-charge status update for payment %s failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, paymentID, cErr)
+8
View File
@@ -483,6 +483,10 @@ func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID st
FROM refunds r
JOIN payments p ON r.payment_id = p.id
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
-- Tips are excluded from TotalPaid above, so a tip refund must
-- equally be excluded here — otherwise a refunded tip would
-- subtract from the paid total and re-open booking capacity.
AND p.payment_type <> 'tip'
GROUP BY p.booking_id
) rr ON b.id = rr.booking_id
WHERE b.id = $1
@@ -521,6 +525,10 @@ func (s *PaymentService) GetBookingRemainingBalancePence(ctx context.Context, bo
FROM refunds r
JOIN payments p ON r.payment_id = p.id
WHERE p.booking_id = $1 AND r.status = 'completed'
-- A tip refund returns gratuity, not booking money — it must not
-- re-open booking charge capacity (mirror of the paid_total tip
-- exclusion above).
AND p.payment_type <> 'tip'
)
-- Money-safety (M-cap): refunds return money, so they re-open booking
-- capacity — remaining = total - paid + refunded. LEAST clamps the cap
+26 -23
View File
@@ -691,11 +691,12 @@ const replayLegitimateRetryWindow = 22 * time.Hour
// which makes a retained-key dedup return the ORIGINAL charge with created <
// r.CreatedAt. Without this tolerance the sweep would declare that original
// payment a "new charge" and auto-refund a charge the customer legitimately
// authorized. Anything at or before row.CreatedAt is therefore treated as
// AMBIGUOUS (never auto-refunded, never rescued) — see
// reconcileStalePaymentByKey. A payment created AFTER row.CreatedAt +
// replayLegitimateRetryWindow remains the provable expired-key duplicate.
const replayRescueLowerBoundSkew = time.Minute
// authorized. A payment created within the skew BEFORE the row is therefore the
// ORIGINAL and is rescued (Loop B MEDIUM — a >1min-ahead DB clock previously
// stranded such a charge pending until the 24h blind-fail); only a payment
// created more than the skew before the row, or after row.CreatedAt +
// replayLegitimateRetryWindow, is ambiguous / a provable expired-key duplicate.
const replayRescueLowerBoundSkew = 5 * time.Minute
// replayMatchesRowAmount reports whether the replayed payment charged the same
// amount the pending row records — the amount the sweep's replay body repeats
@@ -728,7 +729,14 @@ func replayWithinLegitimateWindow(r staleRow, pr *square.PaymentResult) bool {
if !ok || r.CreatedAt.IsZero() {
return false
}
return !created.Before(r.CreatedAt) && !created.After(r.CreatedAt.Add(replayLegitimateRetryWindow))
// The lower bound is replayRescueLowerBoundSkew BEFORE the row: a DB clock
// running ahead of Square's (independent NTP drift, VM pause/resume) can
// make a retained-key dedup return the ORIGINAL charge with created_at
// slightly before the pending row. Such a payment cannot be a provably-new
// expired-key replay (those land ~22h AFTER the row), so within the skew
// tolerance it is the legitimate original and must be rescued — not left
// pending to strand the customer's authorized charge (Loop B MEDIUM).
return !created.Before(r.CreatedAt.Add(-replayRescueLowerBoundSkew)) && !created.After(r.CreatedAt.Add(replayLegitimateRetryWindow))
}
// isSavedCardSource reports whether a Square source id is a card-on-file
@@ -769,10 +777,10 @@ func parseReplayedCreatedAt(pr *square.PaymentResult) (time.Time, bool) {
// replayed payment that cannot be proven to be the original (or a retry
// within the legitimate window) is never rescued (the row stays pending, a
// CRITICAL log is raised and an admin notification inserted), so a hidden
// second charge can never masquerade as the original one. The caller further
// refuses to auto-refund a payment created BEFORE the row (replayRescueLowerBoundSkew
// tolerance — a DB clock ahead of Square's can make the retained-key original
// look slightly older): such a payment is not provably a new charge.
// second charge can never masquerade as the original one. The lower bound of
// the legitimate window is replayRescueLowerBoundSkew BEFORE the row: a DB
// clock ahead of Square's can make the retained-key original look slightly
// older, and such a payment is the legitimate original, not a new charge.
//
// The check runs ONLY against real Square timestamps: it is gated off in an
// explicit dev/mock env because the dev mock returns payments whose CreatedAt
@@ -973,20 +981,15 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
}
// B1 (MEDIUM 2): a replayed COMPLETED payment created BEFORE the
// pending row is NOT provably a new expired-key replay — those land
// ~22h AFTER the row. A retained-key dedup returns the ORIGINAL
// charge, and a DB clock running AHEAD of Square's (rows are
// inserted pending-first; independent NTP drift, VM pause/resume)
// can make that original's created_at lag the row's by up to
// replayRescueLowerBoundSkew. Auto-refunding it would reverse a
// legitimate payment the customer authorized, so any before-row
// created_at is ambiguous: the row is left PENDING with a CRITICAL
// notification for manual reconciliation.
// ~22h AFTER the row. Only genuinely-before payments reach this
// line: a payment within replayRescueLowerBoundSkew of the row is
// already treated as the ORIGINAL retained-key charge by
// replayWithinLegitimateWindow and rescued. This one is far enough
// before the row that no clock skew can explain it — the row is
// left PENDING with a CRITICAL notification for manual
// reconciliation, never auto-refunded.
if created.Before(r.CreatedAt) {
skew := "well before"
if !created.Before(r.CreatedAt.Add(-replayRescueLowerBoundSkew)) {
skew = "slightly before (within the clock-skew tolerance)"
}
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created %s the pending row %s — cannot prove it is a NEW expired-key replay (a DB clock ahead of Square's can make a retained-key original look slightly older) — leaving row %s PENDING — MANUAL RECONCILIATION REQUIRED: verify at Square whether this is a second charge before refunding", table, pr.ID, skew, r.ID, r.ID)
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created %s before the pending row %s (beyond the %s clock-skew tolerance) — cannot prove it is a NEW expired-key replay nor the ORIGINAL charge — leaving row %s PENDING — MANUAL RECONCILIATION REQUIRED: verify at Square whether this is a second charge before refunding", table, pr.ID, created.Sub(r.CreatedAt).Round(time.Minute), r.ID, replayRescueLowerBoundSkew, r.ID)
}
lag := created.Sub(r.CreatedAt).Round(time.Minute).String()
// B1: the replayed COMPLETED payment is a REAL charge the customer
+114 -12
View File
@@ -1263,15 +1263,16 @@ func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing
// TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending locks
// the B1 lower-bound clock-skew tolerance: a replayed COMPLETED payment created
// slightly BEFORE the pending row must NOT be auto-refunded as a "new charge".
// Rows are inserted pending-first (row.CreatedAt precedes Square's created_at by
// BEFORE the pending row must NOT be auto-refunded as a "new charge". Rows are
// inserted pending-first (row.CreatedAt precedes Square's created_at by
// ~0.5-1s), and a DB clock running AHEAD of Square's (independent NTP drift, VM
// pause/resume) makes a retained-key dedup return the ORIGINAL charge with
// created < row.CreatedAt. Such a payment cannot be proven to be a new
// expired-key replay (those land ~22h after the row), so auto-refunding it would
// reverse a legitimate charge — the row is left PENDING with a CRITICAL
// notification instead. Sequential (flips SQUARE_ENVIRONMENT), like the sibling
// B1/A1 tests.
// created < row.CreatedAt. A payment within replayRescueLowerBoundSkew of the
// row is that clock-skewed ORIGINAL and is rescued (locked by
// TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues); this
// fixture sits BEYOND the 5-minute tolerance, where no clock skew can explain
// it — the row is left PENDING with a CRITICAL notification instead. Sequential
// (flips SQUARE_ENVIRONMENT), like the sibling B1/A1 tests.
func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
@@ -1298,15 +1299,15 @@ func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(t *
t.Fatalf("failed to age the stale payment: %v", err)
}
// The replayed COMPLETED payment is the ORIGINAL charge under a retained
// key whose created_at lags the DB row's by 2s — the DB clock running ahead
// of Square's. Seeding from the row's own timestamp (minus 2s) keeps the
// lag deterministic.
// The replayed COMPLETED payment is created 10 minutes BEFORE the row —
// beyond the 5-minute clock-skew tolerance, so it cannot be a clock-skewed
// retained-key original and must remain ambiguous. Seeding from the row's
// own timestamp keeps the lag deterministic.
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)
}
skewedCreated := rowCreatedAt.Add(-2 * time.Second)
skewedCreated := rowCreatedAt.Add(-10 * time.Minute)
origClient := SquareClient
mock := square.NewDevClient()
@@ -1370,6 +1371,107 @@ func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(t *
}
}
// TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues locks the
// Loop B MEDIUM lower-bound fix: a replayed COMPLETED payment created slightly
// BEFORE the pending row — within the 5-minute replayRescueLowerBoundSkew — is
// the clock-skewed ORIGINAL charge under a retained key (a DB clock ahead of
// Square's makes a retained-key dedup return the original with created <
// row.CreatedAt), NOT a provably-new duplicate. It must be RESCUED to
// 'completed'; stranding it pending (the pre-fix behavior for any before-row
// payment) would leave the customer's legitimately-authorized charge to resolve
// only via the 24h blind-fail as a WARN'd failed payment. Sequential (flips
// SQUARE_ENVIRONMENT), like the sibling B1/A1 tests.
func TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
const key = "key-retained-within-skew"
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)
}
// The replayed COMPLETED payment is the ORIGINAL charge under a retained
// key whose created_at lags the DB row's by 2 minutes — inside the 5-minute
// clock-skew tolerance. Seeding from the row's own timestamp keeps the lag
// deterministic.
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)
}
skewedCreated := rowCreatedAt.Add(-2 * time.Minute)
origClient := SquareClient
mock := square.NewDevClient()
t.Setenv("SQUARE_ENVIRONMENT", "production")
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
Status: "COMPLETED",
ID: "pay_original_within_skew",
SquarePayID: "pay_original_within_skew",
CreatedAt: skewedCreated.Format(time.RFC3339Nano),
}}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
// Within-tolerance before-row: the clock-skewed ORIGINAL is rescued, never
// auto-refunded and never left pending.
var status, sqPayID string
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "completed" {
t.Errorf("expected a within-tolerance before-row replay rescued to 'completed', got %q", status)
}
if sqPayID != "pay_original_within_skew" {
t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_within_skew", sqPayID)
}
var refundCount int
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil {
t.Fatalf("failed to count refunds: %v", err)
}
if refundCount != 0 {
t.Errorf("expected NO auto-refund of a within-tolerance before-row original, got %d refund rows", refundCount)
}
}
// TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues locks the B2
// 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
+37 -4
View File
@@ -17,6 +17,7 @@ import (
"crussell/db"
"crussell/internal/square"
"crussell/internal/twofa"
"crussell/internal/validators"
"crussell/mw"
@@ -888,6 +889,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
}
// cardUserID is the owner of the charged saved card (till sales are not
// user-scoped). Resolved in the saved_card case below; hoisted here because
// the post-charge 2FA consumption + audit after the Square call need it.
var cardUserID sql.NullString
switch req.PaymentMethod {
case "cash":
saleStatus = "completed"
@@ -909,8 +915,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// The till path is not user-scoped, so fetch the card's owner along with
// the charge details — the owner is needed to lazily provision a Square
// customer if the row predates P14 (R6).
var cardUserID sql.NullString
// customer if the row predates P14 (R6). cardUserID is declared at
// function scope (before the switch) because the post-charge 2FA
// consumption + audit need it after the Square call.
err = tx.QueryRow(ctx, `
SELECT user_id, COALESCE(square_card_id, ''), COALESCE(square_customer_id, '')
FROM user_saved_cards
@@ -947,8 +954,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
// 2FA gating (C5): charging a customer's saved card requires 2FA when
// the feature is enforced.
if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode) {
// the feature is enforced. consume=false (MEDIUM-2): the code is
// verified here but only NULLed once the charge reaches its terminal
// success state below (ConsumePendingCode), so a failed/ambiguous
// Square charge does NOT burn the operator-relayed code and a same-key
// retry can re-verify the SAME code.
if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, false) {
return
}
@@ -1274,6 +1285,28 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
return
}
// MEDIUM-2: a saved-card till charge reached its terminal SUCCESS state
// — consume the verified 2FA code now (the gate verified without
// consuming, so a failed/ambiguous charge did not burn the code and a
// same-key retry could reuse it). Best-effort after the completion
// write: a consume failure cannot undo the completed sale, it only
// leaves the code valid until its 10-minute expiry.
if req.PaymentMethod == "saved_card" && cardUserID.Valid {
if consErr := twofa.ConsumePendingCode(ctx, db.Conn, cardUserID.String); consErr != nil {
log.Printf("CRITICAL: Square payment %s succeeded but consuming the 2FA code for user %s failed: %v — MANUAL RECONCILIATION REQUIRED", paymentResult.SquarePayID, cardUserID.String, consErr)
}
// MEDIUM-3a: record the admin-initiated saved-card till charge in
// admin_audit_log (mirroring giftcards.go's balance_check audit).
insertAdminAuditCharge(ctx, adminID, cardUserID.String, "till_saved_card_charge", map[string]any{
"till_sale_id": tillSaleID,
"item_type": req.ItemType,
"gift_card_id": giftCardID,
"amount": req.Amount,
"card_last4": paymentResult.CardLast4,
"square_payment_id": paymentResult.SquarePayID,
})
}
saleStatus = "completed"
}
+21 -10
View File
@@ -75,14 +75,18 @@ func (s *PaymentService) UserTwoFactorEnabled(ctx context.Context, userID string
// stored pending 2FA code. It is a thin delegation shim over
// twofa.VerifyForUser — the single source of truth for the verification core
// (per-user brute-force lockout, constant-time compare, legacy pre-pepper
// hash fallback, code lifetime). consume=true is passed so a verified code is
// SINGLE-USE: the gate NULLs the pending code on success, so one code
// authorizes exactly one saved-card charge (not unlimited charges for its
// 10-minute lifetime). It returns nil on a valid code, or a classified
// twofa.ErrIncorrect / twofa.ErrLockedOut / twofa.ErrMissingOrExpired (or a
// wrapped DB error) for the caller to map to the correct HTTP status.
func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error {
return twofa.VerifyForUser(ctx, userID, code, true)
// hash fallback, code lifetime). consume=true makes a verified code SINGLE-USE
// immediately (the pending code is NULLed on success); consume=false verifies
// WITHOUT consuming (MEDIUM-2 — the saved-card CHARGE gates pass false and
// defer consumption to the completed-charge transaction via
// twofa.ConsumePendingCode, so a failed Square charge does not burn the code;
// the save-card SAVE gate passes true because saving a card is a terminal
// operation with no downstream charge to attach consumption to). It returns nil
// on a valid code, or a classified twofa.ErrIncorrect / twofa.ErrLockedOut /
// twofa.ErrMissingOrExpired (or a wrapped DB error) for the caller to map to
// the correct HTTP status.
func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consume bool) error {
return twofa.VerifyForUser(ctx, userID, code, consume)
}
// requireTwoFactorForCardAccess gates the saved-card online payment paths
@@ -100,6 +104,13 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error
// operator relays (delivery is the user package's build-dependent [2FA] log /
// email-SMS channel).
//
// consume controls whether a verified code is NULLed immediately (consume=true
// — the save-card SAVE gate) or left intact for the caller to consume when its
// operation reaches a terminal success state (consume=false — the saved-card
// CHARGE gates; see verifyPendingTwoFactorCode / twofa.ConsumePendingCode,
// MEDIUM-2). In every case the 5-attempt lockout and the
// code-destroy-on-lockout semantics are unchanged (twofa.Check).
//
// The code check is delegated to crussell/internal/twofa via
// verifyPendingTwoFactorCode, so this gate participates in the SAME per-user
// brute-force lockout (5 failed attempts invalidate the pending code) as the
@@ -109,7 +120,7 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error
//
// On any denial an error JSON is written (parseable by the frontend via
// extractErrorMessage) and false is returned — the caller must abort the charge.
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string) bool {
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string, consume bool) bool {
if !twoFactorEnforced() {
return true
}
@@ -136,7 +147,7 @@ func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, servi
mw.RespondError(w, http.StatusForbidden, "A two-factor verification code is required to use this saved card. Ask the customer for their current code.")
return false
}
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode); {
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
case err == nil:
return true
case errors.Is(err, twofa.ErrIncorrect):
+109 -17
View File
@@ -108,7 +108,7 @@ func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "mock")
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", ""))
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", false))
require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced")
}
@@ -121,7 +121,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456")
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", false)
require.False(t, ok)
require.Equal(t, http.StatusForbidden, w.Code)
var body map[string]string
@@ -137,7 +137,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
// B10: the enabled setup flag alone must NOT unlock the gate.
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "")
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", false)
require.False(t, ok)
require.Equal(t, http.StatusForbidden, w.Code)
})
@@ -148,7 +148,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
seedTwoFAPendingCode(t, tx, userID, "424242")
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"))
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false))
require.Equal(t, http.StatusOK, w.Code)
})
@@ -158,7 +158,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
seedTwoFAPendingCode(t, tx, userID, "424242")
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000")
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", false)
require.False(t, ok)
require.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]string
@@ -169,7 +169,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456"))
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", false))
require.Equal(t, http.StatusForbidden, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
@@ -177,12 +177,14 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
})
}
// TestRequireTwoFactorForCardAccess_CodeIsSingleUse pins the finding-1 fix: a
// code verified through the gate is CONSUMED (the pending code is NULLed), so
// the same code cannot authorize a second saved-card charge within its
// 10-minute lifetime. The second attempt with the same code is denied with the
// documented "expired — request a new one" 400.
func TestRequireTwoFactorForCardAccess_CodeIsSingleUse(t *testing.T) {
// TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume pins the MEDIUM-2
// contract: the charge gate verifies the code WITHOUT consuming it (consume
// happens later, at the charge's terminal SUCCESS state via
// twofa.ConsumePendingCode), so a failed/ambiguous Square charge does NOT burn
// the operator-relayed code — a same-key retry can re-verify the SAME code.
// Only an explicit ConsumePendingCode (the completed-charge path) NULLs it,
// after which the code is dead ("expired").
func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
@@ -191,17 +193,27 @@ func TestRequireTwoFactorForCardAccess_CodeIsSingleUse(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"), "first use of the code must pass the gate")
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "first gate pass must succeed")
require.Equal(t, http.StatusOK, w.Code)
// The verified code must now be consumed (NULLed) in the DB.
// The code must still be present — the gate verified WITHOUT consuming.
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "a verified gate code must be consumed (NULLed)")
require.True(t, pendingHash.Valid, "the gate must NOT consume the code (MEDIUM-2)")
// A second charge attempt with the same code must be denied as expired.
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
w = httptest.NewRecorder()
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"), "a consumed code must not pass the gate twice")
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a not-yet-consumed code must pass the gate again on retry")
require.Equal(t, http.StatusOK, w.Code)
// Consumption happens at the charge's terminal SUCCESS state.
require.NoError(t, twofa.ConsumePendingCode(ctx, tx, userID))
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "ConsumePendingCode must NULL the pending code")
// A further attempt with the consumed code is denied as expired.
w = httptest.NewRecorder()
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a consumed code must not pass the gate")
require.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
@@ -427,3 +439,83 @@ func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted
// pins the Loop B MEDIUM gate-ordering fix: the save-card 2FA gate runs AFTER
// the idempotency dedup's completed short-circuit. A same-key lost-response
// retry re-sends the SAME single-use verification code that the original
// attempt already consumed; if the gate ran first it would 400 "Verification
// code expired". With the gate below the dedup, the retry returns the
// already-completed payment instead of re-entering the gate.
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "778899")
cardToken := "cnon:2fa-save-card-retry"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-retry",
VerificationCode: "778899",
}
handler := withNonGuest(CreateBookingPayment)
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Same-key retry re-sends the identical request, whose code is now
// consumed. The completed-dedup must return the payment before the gate.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed payment, not re-run the gate: %s", w2.Body.String())
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Equal(t, 1, payCount, "the retry must not create a second payment")
}
// TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted pins
// the Loop B MEDIUM gate-ordering fix on the tip endpoint: the saved-card
// charge 2FA gate runs AFTER the tip idempotency dedup's completed
// short-circuit. A same-key lost-response retry re-sends the SAME single-use
// verification code the original attempt consumed; with the gate first it would
// 400 "expired", with the gate below the dedup the retry returns the completed
// tip instead.
func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "667788")
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_tip_retry", "VISA", "4321")
require.NoError(t, err)
req := CreateTipPaymentRequest{
Amount: 500,
CardID: &cardID,
IdempotencyKey: "2fa-tip-saved-card-retry",
VerificationCode: "667788",
}
handler := withNonGuest(CreateTipPayment)
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Same-key retry re-sends the identical request, whose code is now
// consumed. The completed-dedup must return the tip before the gate.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed tip, not re-run the gate: %s", w2.Body.String())
var tipCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&tipCount))
require.Equal(t, 1, tipCount, "the retry must not create a second tip")
}
+4 -2
View File
@@ -340,9 +340,11 @@ func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCo
// Coordination contract for the payments agent (B6/B10): handlers/payments
// cannot import handlers/user — handlers/user imports handlers/payments
// (TwoFactorEnforced, SquareClient), so a payments→user import is a cycle. The
// payments gate must call twofa.VerifyForUser(ctx, userID, code) from
// payments gate must call twofa.VerifyForUser(ctx, userID, code, consume) from
// crussell/internal/twofa (the shared home of this verification core) instead
// of importing this package.
// of importing this package. Since MEDIUM-2 the payments saved-card CHARGE
// gates pass consume=false and NULL the code at the charge's terminal success
// via twofa.ConsumePendingCode; the save-card SAVE gates pass consume=true.
func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error {
st := twoFAAttemptStateFor(userID)
st.Mu.Lock()
+8 -1
View File
@@ -49,8 +49,15 @@ func twoFAEnsureIssueAllowed() error { return nil }
// the user out-of-band until email/SMS lands. Production builds log it ONLY
// when the operator explicitly opts in via TWO_FACTOR_ALLOW_LOG_DELIVERY=true
// (see twofa_prod.go); otherwise they refuse issuance up front.
//
// MEDIUM-3b: the user id and the plaintext code are written to SEPARATE log
// lines so a log line cannot trivially pair a code with its owner. The two
// lines are still correlated by proximity, but a single-line grep or a log
// redaction rule that masks a "code" pattern no longer discloses the identity
// in the same record.
func twoFADeliverCode(userID, label, code string) {
log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code)
log.Printf("[2FA] code delivery requested (user=%s, purpose=%s)", userID, label)
log.Printf("[2FA] code: %s", code)
}
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
+4 -2
View File
@@ -94,10 +94,12 @@ func twoFAEnsureIssueAllowed() error {
// unreachable. With the flag set, the code is written to the [2FA] log line
// and an operator relays it to the user out-of-band, exactly like the
// documented dev flow — the operator has accepted the risk of log-based
// delivery.
// delivery. MEDIUM-3b: the user id and the plaintext code go to SEPARATE log
// lines so a single record cannot trivially pair a code with its owner.
func twoFADeliverCode(userID, label, code string) {
if os.Getenv(twoFAAllowLogDeliveryEnv) == "true" {
log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code)
log.Printf("[2FA] code delivery requested (user=%s, purpose=%s)", userID, label)
log.Printf("[2FA] code: %s", code)
}
// Otherwise: deliberate no-op — never log the plaintext code by default.
}
+16 -15
View File
@@ -153,17 +153,17 @@ func webhookDBContext() (context.Context, context.CancelFunc) {
// squareEnvironmentMismatch reports whether the webhook's square-environment
// header conflicts with the deployment's configured SQUARE_ENVIRONMENT.
//
// The check is enforced ONLY when the configured environment is a known real
// Square environment (production/sandbox): those are the deployments where a
// mis-pointed subscription (e.g. a sandbox subscription posting to the
// production URL + signing key) would process events against the wrong state.
// In dev/mock deployments — or an empty/unknown SQUARE_ENVIRONMENT, which the
// rest of the backend treats as fail-closed production but is not a specific
// real environment to compare against — the header is informational and a
// mismatch is not rejectable. The dev/mock determination delegates to the
// shared payments.IsExplicitDevOrMockEnv (handlers/payments/twofa.go) rather
// than re-implementing the env-value list, so this check and the 2FA gate can
// never diverge on what counts as the dev/mock stack.
// The check is enforced for every non-dev/mock deployment: configured
// production and sandbox are compared directly, and an EMPTY or UNKNOWN
// configured SQUARE_ENVIRONMENT is treated as PRODUCTION (LOW-4) — matching how
// the rest of the backend treats empty/unknown env fail-closed (main.go:214
// and payments twofa.go:40-42) — so a sandbox subscription mis-pointed at an
// unconfigured production URL cannot process sandbox events against production
// state. In dev/mock deployments the header is informational and a mismatch is
// not rejectable; the dev/mock determination delegates to the shared
// payments.IsExplicitDevOrMockEnv (handlers/payments/twofa.go) rather than
// re-implementing the env-value list, so this check and the 2FA gate can never
// diverge on what counts as the dev/mock stack.
//
// An absent header is allowed through: real Square deliveries always send it,
// so a missing header in an enforced deployment is a non-Square client (which
@@ -189,10 +189,11 @@ func squareEnvironmentMismatch(headerEnv string) bool {
return false
}
configured := strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT")))
if configured != "production" && configured != "sandbox" {
// Empty/unknown configured environment — no specific real environment
// to enforce against; the header is informational only.
return false
if configured != "sandbox" {
// Empty/unknown configured environment is treated as PRODUCTION for the
// env check (LOW-4), matching the fail-closed production default the
// rest of the backend applies to empty/unknown SQUARE_ENVIRONMENT.
configured = "production"
}
return headerEnv != configured
}
@@ -998,6 +998,45 @@ func TestHandleSquareWebhook_EnvMismatch_DevNotEnforced(t *testing.T) {
}
}
// TestSquareEnvironmentMismatch_EmptyConfigTreatedAsProduction pins the LOW-4
// fix: an EMPTY/UNKNOWN configured SQUARE_ENVIRONMENT is treated as
// PRODUCTION for the webhook env check (matching main.go:214 and payments
// twofa.go:40-42's fail-closed default), so a sandbox subscription mis-pointed
// at an unconfigured production URL is rejected instead of silently accepted.
func TestSquareEnvironmentMismatch_EmptyConfigTreatedAsProduction(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "")
// Empty/unknown configured env behaves exactly like "production": a
// sandbox header is a mismatch, a production header is a match.
if !squareEnvironmentMismatch("sandbox") {
t.Error("empty SQUARE_ENVIRONMENT must be treated as production (sandbox header = mismatch)")
}
if squareEnvironmentMismatch("production") {
t.Error("empty SQUARE_ENVIRONMENT treated as production must match a production header")
}
if squareEnvironmentMismatch("PRODUCTION") {
t.Error("header comparison must stay case-insensitive")
}
if squareEnvironmentMismatch("") {
t.Error("an absent header must stay allowed")
}
// An explicit sandbox config still enforces sandbox semantics.
t.Setenv("SQUARE_ENVIRONMENT", "sandbox")
if squareEnvironmentMismatch("sandbox") {
t.Error("sandbox config must match a sandbox header")
}
if !squareEnvironmentMismatch("production") {
t.Error("sandbox config must reject a production header")
}
// An explicit dev/mock config never enforces the header check.
t.Setenv("SQUARE_ENVIRONMENT", "mock")
if squareEnvironmentMismatch("sandbox") {
t.Error("dev/mock deployments must not enforce the header check")
}
}
// =============================================================================
// Bounded post-dispatch DB contexts (finding b)
// =============================================================================
+57 -20
View File
@@ -11,7 +11,7 @@
//
// Contract for the payments gate:
//
// err := twofa.VerifyForUser(ctx, userID, code, true) // consume = true
// err := twofa.VerifyForUser(ctx, userID, code, false) // consume = false
// if err != nil {
// switch {
// case errors.Is(err, twofa.ErrIncorrect):
@@ -25,13 +25,19 @@
// }
// }
//
// A correct code is SINGLE-USE on the payments gate: the gate passes
// consume=true, so the stored pending-code digest and its expiry are NULLed in
// the same critical section as the successful check. One code therefore
// authorizes exactly one saved-card charge, never unlimited charges for its
// 10-minute lifetime. The interactive setup/disable flows pass consume=false —
// they clear the pending fields themselves on success (enableTwoFA /
// disableTwoFA), so the code must stay valid through their whole handshake.
// The payments saved-card charge gate verifies WITH consume=false (MEDIUM-2):
// the code is checked at gate time but only NULLed when the charge reaches a
// TERMINAL SUCCESS state (the handlers call twofa.ConsumePendingCode inside the
// transaction that records the completed charge). A failed/ambiguous Square
// charge therefore does NOT burn the code — the same-key retry re-verifies the
// SAME operator-relayed code instead of hitting a 400 "expired". Consumption is
// idempotent, so a code still authorizes exactly one completed charge (and
// remains bounded by its 10-minute lifetime). The interactive setup/disable
// flows pass consume=false too — they clear the pending fields themselves on
// success (enableTwoFA / disableTwoFA), so the code must stay valid through
// their whole handshake. The ONLY remaining consume=true caller is the
// save-card SAVE gate (handlers/payments), where saving a card is itself a
// terminal operation with no downstream charge to attach consumption to.
//
// The failed-attempt counter is keyed per user and resets ONLY on a successful
// verify (or after the 10-minute attempt window elapses) — never on a fresh
@@ -278,13 +284,16 @@ const (
// check. A correct code resets the attempt counter and returns OK. An incorrect
// code increments the counter and, on the 5th consecutive failure, invalidates
// the pending code (lockout). A missing or expired pending code returns
// MissingOrExpired. consume makes a correct code single-use: the stored digest
// and its expiry are NULLed immediately, so one code cannot authorize a second
// operation within its lifetime (the payments saved-card gate passes true; the
// interactive setup/disable flows pass false and clear the pending fields
// themselves on success). The returned error is non-nil only for DB failures
// (callers return 500); a lockout's pending-code invalidation failure is logged
// here and still reported as a lockout.
// MissingOrExpired. consume makes a correct code single-use IMMEDIATELY: the
// stored digest and its expiry are NULLed right here, so one code cannot
// authorize a second operation within its lifetime. The interactive
// setup/disable flows pass false and clear the pending fields themselves on
// success. The payments saved-card charge gate now ALSO passes false (MEDIUM-2):
// it verifies at gate time and defers consumption to the completed-charge
// transaction via ConsumePendingCode, so a failed Square charge does not burn
// the code. The returned error is non-nil only for DB failures (callers return
// 500); a lockout's pending-code invalidation failure is logged here and still
// reported as a lockout.
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
st.Count.Store(0)
@@ -373,6 +382,32 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
return OK, nil
}
// ConsumePendingCode NULLs the user's pending 2FA code digest and expiry. The
// payments saved-card charge gate verifies WITHOUT consuming (MEDIUM-2) and the
// handlers call this when the charge reaches a TERMINAL SUCCESS state — inside
// the transaction that records the completed charge when one exists — so the
// code is consumed atomically with the charge OUTCOME, not the gate. A failed
// or ambiguous Square charge leaves the code intact and the same-key retry can
// re-verify the SAME code. Idempotent: consuming an already-NULL pending code
// is a no-op, so a code still authorizes exactly one completed charge and can
// never authorize a second after success. Accepts a db.Querier so the write can
// ride the caller's transaction (pgx.Tx) or the pool proxy.
func ConsumePendingCode(ctx context.Context, q db.Querier, userID string) error {
if userID == "" {
return nil
}
_, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID)
if err != nil {
return fmt.Errorf("2FA consume pending code: %w", err)
}
return nil
}
// Classifying errors returned by VerifyForUser.
var (
// ErrIncorrect reports a code that does not match the user's pending code.
@@ -390,11 +425,13 @@ var (
// It returns nil on a correct code, or one of ErrIncorrect / ErrLockedOut /
// ErrMissingOrExpired (or a DB error, wrapped). This is the entry point for
// 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:
// the pending-code digest and its expiry are NULLed in the same critical
// section as the successful check (see Check), so one code authorizes exactly
// one gate pass. The interactive setup/disable flows pass false — they clear
// the pending fields themselves on success (enableTwoFA / disableTwoFA).
// real, freshly-verified challenge. consume makes a correct code single-use
// IMMEDIATELY (the pending-code digest and expiry are NULLed in the same
// critical section as the successful check see Check). The payments SAVED-
// CARD CHARGE gate passes false and consumes later via ConsumePendingCode
// (MEDIUM-2) so a failed charge does not burn the code; the save-card SAVE
// gate and the interactive setup/disable flows pass false and clear the
// pending fields themselves on success (enableTwoFA / disableTwoFA).
func VerifyForUser(ctx context.Context, userID, code string, consume bool) error {
st := StateFor(userID)
st.Mu.Lock()
+25
View File
@@ -75,6 +75,31 @@ func TestVerifyForUser_LockoutAndMissing(t *testing.T) {
require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", true), ErrMissingOrExpired)
}
// TestConsumePendingCode pins the MEDIUM-2 contract: ConsumePendingCode NULLs
// the stored pending-code digest and expiry (idempotently), and is the ONLY
// place a verified-but-unconsumed code dies on the saved-card charge path.
func TestConsumePendingCode(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPending(t, ctx, tx, userID, "123456")
// A code verified WITHOUT consuming stays valid (the saved-card charge gate
// path, MEDIUM-2) — re-verification must keep working until consumption.
require.NoError(t, VerifyForUser(ctx, userID, "123456", false), "verify-without-consume must pass")
require.NoError(t, VerifyForUser(ctx, userID, "123456", false), "an unconsumed code must still verify on a same-key retry")
require.NoError(t, ConsumePendingCode(ctx, tx, userID), "explicit consumption at charge success must succeed")
require.ErrorIs(t, VerifyForUser(ctx, userID, "123456", false), ErrMissingOrExpired, "a consumed code must no longer verify")
// Consumption is idempotent — a second call (e.g. a retried completed
// charge) is a no-op, never an error.
require.NoError(t, ConsumePendingCode(ctx, tx, userID), "consuming an already-consumed code must be a no-op")
// An unknown user is a no-op too.
require.NoError(t, ConsumePendingCode(ctx, tx, "000000000000"))
}
// TestVerifyForUser_AttemptStateMapPersists exercises the shared per-user
// attempt map directly (the state the payments gate shares with the interactive
// endpoints): the map is bounded and a locked-out record is never evicted.
+9 -1
View File
@@ -701,10 +701,18 @@ func main() {
r.With(mw.RequireAuth, mw.RequireVerified, limitBody(uploadBodyLimit)).Post("/user/profile-picture", user.UploadProfilePictureHandler)
// Admin-only (no rate limit - trusted users with authenticated sessions)
// Admin-only. The group now carries a per-IP limiter (300/min) to bound
// the per-request DB amplification of the auth path (VerifyToken runs a
// JTI-revocation query + a family-alive query per request — MEDIUM-1):
// an unthrottled admin surface lets a single compromised admin session
// hammer the DB. 300/min is far above any legitimate admin UI usage and
// keys per-IP (RateLimitByUser would be a no-op — there is exactly one
// admin account, so per-user == global). The trusted-session model is
// unchanged; this only bounds amplification.
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(300, time.Minute))
r.Use(limitBody(defaultBodyLimit))
r.Route("/admin/services", func(r chi.Router) {
+11 -21
View File
@@ -1,27 +1,17 @@
/**
* Generate a UUID v4 string.
* Generate a UUID v4 string using `crypto.randomUUID()`.
*
* Uses `window.crypto.getRandomValues()` when available (secure context),
* falls back to `Math.random()` for non-secure contexts (e.g. plain HTTP).
*
* Safe for all environments — does NOT rely on `crypto.randomUUID()`
* which requires a secure context (HTTPS).
* The app is HTTPS-only (secure context), so `crypto.randomUUID()` is always
* available in the browser and in Node 19+ (vitest/dev server). Idempotency
* keys derived here gate Square dedup — they must be unpredictable, so this
* HARD-FAILS (throws) rather than silently falling back to `Math.random()` in
* a non-secure context (LOW-5). No fallback is ever used.
*/
export function generateUUID(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
if (typeof globalThis.crypto?.randomUUID !== 'function') {
throw new Error(
'generateUUID requires crypto.randomUUID() (secure context). The app is HTTPS-only; refusing to fall back to Math.random().'
);
}
// UUID v4 marker and variant
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
return [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
return globalThis.crypto.randomUUID();
}
+9 -1
View File
@@ -388,7 +388,15 @@ CREATE TABLE bookings (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by CHAR(12),
idempotency_key VARCHAR(64) UNIQUE,
out_of_hours BOOLEAN NOT NULL DEFAULT FALSE
out_of_hours BOOLEAN NOT NULL DEFAULT FALSE,
-- Once-only loyalty-stamp marker (Loop B MEDIUM — stamp farming via refund
-- + re-charge): the stamp is awarded at most ONCE per booking, no matter
-- how many times the booking is re-completed. A refund never moves the
-- booking out of 'in_progress', so a re-payment re-completes it; without
-- this marker each in_progress→completed transition would mint another
-- stamp with no net merchant cash flow. Set by
-- ApplyBookingCompletionSideEffects only when a stamp is actually awarded.
loyalty_stamp_awarded_at TIMESTAMPTZ
);
CREATE INDEX idx_bookings_userid ON bookings(user_id);