Follow-up to the comprehensive payment-system review. Fixes the issues the review found in the initial integration, plus the rough edges it introduced. Money-safety: - Replay-by-key now replays the FULL original request verbatim from a stored square_request_snapshot, so a retained idempotency key returns the original payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge). - Dev mock mirrors real Square for unknown-key replays: ccof: saved-card sources are charged and rescued; spent cnon: nonces surface ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.) - Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales claw back gift-card funding; event-type strings match Square's real catalog. - Expired-gift-card cancellation refunds set creditFailed (never a phantom 'completed' refund); cancellation refunds lock all payment rows ascending. - Sweep never rescue-completes a gift-card purchase without delivering the card. - Tip no-client-key fallback is a deterministic count-based key under the booking advisory lock (retry-safe, distinct tips don't collapse). - M-cap subtracts completed refunds, clamped to [0, total]. 2FA (PSD2 SCA stand-in) for online saved-card payments: - Full feature: status/setup/verify/disable endpoints, gating helper wired into all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account admin-tab settings UI, frontend gating across all payment surfaces. - Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env. - Verify is brute-force hardened (5-attempt lockout, timing-safe compare); plaintext codes only logged when enforcement is off (dev). - GDPR: anonymize_user also scrubs 2FA columns and staff notes. Infra/docs: - nginx: /api/ response cache removed (cross-user disclosure); port 80 redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS; separate webhook rate-limit zone. - Schema: users 2FA columns; payments/till_sales square_source_id + square_request_snapshot. - Legal docs: gift-card cooling-off, international-transfers section, tips policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected. - Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26 packages green, 2,142 tests, svelte-check clean.
266 lines
9.5 KiB
Go
266 lines
9.5 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/handlers/payments"
|
|
"crussell/internal/dav"
|
|
"crussell/internal/s3"
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// scrubAnonymizedUser2FA nulls the 2FA columns and staff notes that the SQL
|
|
// anonymize_user() function does not scrub: it predates the 2FA columns and
|
|
// intentionally preserves notes. A deleted user's live 2FA credential and any
|
|
// PII in staff notes must not survive erasure, so run this inside the same
|
|
// transaction as anonymize_user() to keep erasure atomic.
|
|
func scrubAnonymizedUser2FA(ctx context.Context, q db.Querier, userID string) error {
|
|
_, err := q.Exec(ctx, `
|
|
UPDATE users
|
|
SET two_factor_enabled = FALSE,
|
|
two_factor_method = NULL,
|
|
two_factor_pending_code_hash = NULL,
|
|
two_factor_pending_code_expires = NULL,
|
|
notes = NULL
|
|
WHERE id = $1
|
|
`, userID)
|
|
return err
|
|
}
|
|
|
|
// DELETE /api/user/account
|
|
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "user not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to fetch user for deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// --- External system scrubbing (BEFORE SQL anonymize) ---
|
|
|
|
// Delete profile picture from S3/R2
|
|
if profilePicURL.Valid && profilePicURL.String != "" && s3.Client != nil {
|
|
// #nosec G118 — intentional background goroutine for async profile pic cleanup
|
|
go func(picURL string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in S3 profile picture deletion: %v", r)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
bucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
|
|
if bucket == "" {
|
|
bucket = "crussell-profile-pics"
|
|
}
|
|
// profiles/{userID}.jpg — matches UploadProfilePictureHandler key format
|
|
key := fmt.Sprintf("profiles/%s.jpg", userID)
|
|
if err := s3.Client.Delete(ctx, bucket, key); err != nil {
|
|
log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err)
|
|
}
|
|
}(profilePicURL.String)
|
|
}
|
|
|
|
// Snapshot the Square card IDs AND customer IDs synchronously BEFORE the
|
|
// SQL anonymization below NULLs square_card_id/square_customer_id, so the
|
|
// background cleanup still has the external Square references it needs
|
|
// (previously the goroutine read the rows itself, racing the anonymize
|
|
// step which wiped them mid-flight). Distinct non-null customer IDs only:
|
|
// a user's saved cards share one provisioned Square customer, so
|
|
// DeleteCustomer runs once per customer. NULL customer IDs (users with
|
|
// no saved cards) are skipped.
|
|
var cardIDs []string
|
|
customerSeen := map[string]bool{}
|
|
var customerIDs []string
|
|
// Capture the client synchronously so the async cleanup goroutine never
|
|
// reads the global payments.SquareClient (which tests swap per-account).
|
|
sqClient := payments.SquareClient
|
|
if sqClient != nil {
|
|
rows, err := db.Conn.Query(r.Context(),
|
|
`SELECT square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
|
|
} else {
|
|
for rows.Next() {
|
|
var cardID, customerID sql.NullString
|
|
if err := rows.Scan(&cardID, &customerID); err != nil {
|
|
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
|
|
continue
|
|
}
|
|
if cardID.Valid && cardID.String != "" {
|
|
cardIDs = append(cardIDs, cardID.String)
|
|
}
|
|
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
|
|
customerSeen[customerID.String] = true
|
|
customerIDs = append(customerIDs, customerID.String)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Warning: Row iteration error for user %s: %v", userID, err)
|
|
}
|
|
rows.Close()
|
|
}
|
|
}
|
|
|
|
// --- SQL-level anonymization/deletion ---
|
|
|
|
if accountRole == "guest" {
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction for guest user deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, err = tx.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete guest user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction for guest user deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction for user anonymization: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to anonymize user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// GDPR erasure gap: anonymize_user() leaves the 2FA columns and staff
|
|
// notes on the row. Scrub them here, in the same transaction, so erasure
|
|
// is atomic with the anonymization.
|
|
if err := scrubAnonymizedUser2FA(ctx, tx, userID); err != nil {
|
|
log.Printf("Failed to scrub 2FA fields for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction for user anonymization: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Create 'user_anonymized' notification for admin audit trail
|
|
}
|
|
|
|
// The local erasure committed: drop the user's cached Square customer id so
|
|
// the erased identity cannot resurface from the process-local cache on a
|
|
// later save-card flow (the Square customer is deleted below and the DB
|
|
// columns are NULLed, but the cache is never touched by either).
|
|
payments.InvalidateSquareCustomerCache(userID)
|
|
|
|
// external Square cleanup fires only after local anonymization/deletion
|
|
// commits, so a failed local tx leaves external state intact for retry.
|
|
if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) {
|
|
// #nosec G118 — intentional background goroutine for async account deletion
|
|
go func(client square.SquareClient, cards, customers []string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in Square cleanup: %v", r)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
for _, cardID := range cards {
|
|
if err := client.DeleteCardOnFile(ctx, cardID); err != nil {
|
|
// TokenPrefix redacts the ccof: card token — the full ID must
|
|
// never reach logs.
|
|
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", square.TokenPrefix(cardID), userID, err)
|
|
}
|
|
}
|
|
for _, customerID := range customers {
|
|
// Square customers are provisioned per-user from a deterministic
|
|
// email-derived key, but the UNIQUE(email) index excludes guest
|
|
// accounts — so a guest and a registered user sharing an email can
|
|
// end up on the SAME Square customer profile (Square dedups within
|
|
// its idempotency window). Deleting it would break the other
|
|
// account's saved-card charges, so skip the deletion when any OTHER
|
|
// non-deleted saved card still references the customer.
|
|
var stillReferenced bool
|
|
if err := db.Conn.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL)
|
|
`, customerID, userID).Scan(&stillReferenced); err != nil {
|
|
log.Printf("Warning: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err)
|
|
continue
|
|
}
|
|
if stillReferenced {
|
|
// PII-redacted customer id — the full id never reaches logs.
|
|
log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID))
|
|
continue
|
|
}
|
|
if err := client.DeleteCustomer(ctx, customerID); err != nil {
|
|
log.Printf("Warning: Failed to delete Square customer %s for user %s: %v", square.TokenPrefix(customerID), userID, err)
|
|
}
|
|
}
|
|
}(sqClient, cardIDs, customerIDs)
|
|
}
|
|
|
|
// Delete CardDAV contact (non-blocking, best-effort)
|
|
if dav.Service != nil {
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in CardDAV contact deletion: %v", r)
|
|
}
|
|
}()
|
|
uri := fmt.Sprintf("%s.vcf", userID)
|
|
if err := dav.Service.DeleteContact(1, uri); err != nil {
|
|
log.Printf("Warning: Failed to delete CardDAV contact for user %s: %v", userID, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|