fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup
Full-scope Loop A restart review (18 findings across money/security/dup-mod): MONEY: - HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking - MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount - MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit - MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx) - LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded SECURITY: - 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure) - Admin 2FA mint now writes admin_audit_log + logs code reuse - Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account - Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts) - family-alive cache invalidated on password change / GDPR erasure - Login lockout keyed per user+IP with a capped ceiling FRONTEND/DUP-MOD: - OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware) - PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard) - requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode) - BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently 26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -14,14 +15,17 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"crussell/auth"
|
||||
"crussell/db"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/dav"
|
||||
"crussell/internal/s3"
|
||||
"crussell/internal/square"
|
||||
"crussell/internal/twofa"
|
||||
"crussell/mw"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// --- Square erasure retry + alerting + durable outbox (GDPR H3 / A1) ---
|
||||
@@ -201,6 +205,15 @@ func clearSquareErasureOutboxRows(ctx context.Context, rowIDs []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAccountRequest carries the re-verification credentials the handler now
|
||||
// requires before erasing an account (finding 3): the current password (always)
|
||||
// and, in enforced environments for a user with 2FA enabled, a fresh one-time
|
||||
// verification code.
|
||||
type DeleteAccountRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
VerificationCode string `json:"verification_code"`
|
||||
}
|
||||
|
||||
// DELETE /api/user/account
|
||||
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
@@ -211,8 +224,10 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var accountRole string
|
||||
var profilePicURL sql.NullString
|
||||
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
|
||||
Scan(&accountRole, &profilePicURL)
|
||||
var passwordHash sql.NullString
|
||||
var twoFactorEnabled bool
|
||||
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url, password_hash, two_factor_enabled FROM users WHERE id = $1`, userID).
|
||||
Scan(&accountRole, &profilePicURL, &passwordHash, &twoFactorEnabled)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
@@ -225,6 +240,48 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Finding 3: deleting an account is irreversible, so the session token alone
|
||||
// must not be enough — an attacker who lifts a token (XSS, leaked localStorage)
|
||||
// must not be able to erase the account. Re-verify the current password
|
||||
// (mirroring ChangePasswordHandler's bcrypt compare) and, in enforced
|
||||
// environments for a user with 2FA enabled, a fresh one-time code consumed by
|
||||
// the shared core (twofa.VerifyForUser with ConsumeOnVerify).
|
||||
var req DeleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if passwordHash.Valid && passwordHash.String != "" {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash.String), []byte(req.CurrentPassword)); err != nil {
|
||||
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
if twoFARequired() && twoFactorEnabled {
|
||||
if req.VerificationCode == "" {
|
||||
http.Error(w, "a two-factor verification code is required to delete the account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch err := twofa.VerifyForUser(ctx, userID, req.VerificationCode, twofa.ConsumeOnVerify); {
|
||||
case err == nil:
|
||||
// Verified: the code is consumed (single-use), matching the saved-card
|
||||
// gate. If the deletion below then fails the user requests a fresh code.
|
||||
case errors.Is(err, twofa.ErrIncorrect):
|
||||
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
||||
return
|
||||
case errors.Is(err, twofa.ErrLockedOut):
|
||||
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
case errors.Is(err, twofa.ErrMissingOrExpired):
|
||||
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
|
||||
return
|
||||
default:
|
||||
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- External system scrubbing (BEFORE SQL anonymize) ---
|
||||
|
||||
// Delete profile picture from S3/R2
|
||||
@@ -393,6 +450,13 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// columns are NULLed, but the cache is never touched by either).
|
||||
payments.InvalidateSquareCustomerCache(userID)
|
||||
|
||||
// Finding 5: anonymize_user()/delete_guest_user() deleted every refresh
|
||||
// token the user held inside the committed transaction. Drop the in-memory
|
||||
// family-alive verdicts for ALL of the user's rotation families so access
|
||||
// tokens minted by those families die on their next verification instead of
|
||||
// riding the 30s family-alive cache TTL.
|
||||
auth.InvalidateFamilyAliveByUser(userID)
|
||||
|
||||
// Build the context used for critical-notification inserts: route through
|
||||
// the request transaction when one is active (tests) so alerts roll back
|
||||
// with the fixture; otherwise fall back to the shared pool (production,
|
||||
|
||||
Reference in New Issue
Block a user