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.
398 lines
13 KiB
Go
398 lines
13 KiB
Go
package user
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"math/big"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/handlers/payments"
|
|
"crussell/mw"
|
|
)
|
|
|
|
// twoFARequired reports whether 2FA enforcement is active in this deployment.
|
|
// The user endpoints and the profile handler expose this to the frontend so it
|
|
// can gate the settings UI.
|
|
func twoFARequired() bool {
|
|
return payments.NewPaymentService().TwoFactorEnforced()
|
|
}
|
|
|
|
// twoFAPendingExpiry is how long a generated verification code stays valid.
|
|
// Loose fake: real email/SMS infrastructure will own this lifetime once it lands.
|
|
const twoFAPendingExpiry = 10 * time.Minute
|
|
|
|
// generateTwoFACode returns a random 6-digit verification code.
|
|
func generateTwoFACode() (string, error) {
|
|
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%06d", n.Int64()), nil
|
|
}
|
|
|
|
// hashTwoFACode returns the SHA-256 hex digest of a verification code. The DB
|
|
// stores only the digest; the plaintext code is only ever logged in unenforced
|
|
// (dev) environments (see SetupTwoFAHandler). The digest is unsalted SHA-256 —
|
|
// peppering it via HMAC-SHA256 with a server-side 2FA_PEPPER secret is a future
|
|
// hardening step once such a secret is provisioned.
|
|
func hashTwoFACode(code string) string {
|
|
sum := sha256.Sum256([]byte(code))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed
|
|
// before the pending code is invalidated and a new one must be requested.
|
|
const twoFAMaxAttempts = 5
|
|
|
|
// twoFAAttemptWindow bounds how long a per-user attempt counter lives before
|
|
// resetting, and doubles as the stale-entry eviction horizon for the map.
|
|
const twoFAAttemptWindow = 10 * time.Minute
|
|
|
|
// twoFAMaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
|
|
// user IDs cannot grow it without bound. Counters are purely in-memory (the DB
|
|
// schema is locked — there is no attempt column), so they reset on process
|
|
// restart; the 10-minute pending-code expiry bounds the practical impact.
|
|
const twoFAMaxTrackedAttempts = 10_000
|
|
|
|
// twoFAAttemptState tracks consecutive failed verify attempts for one user. The
|
|
// per-user mutex serializes the whole verify critical section so concurrent
|
|
// attempts from the same user cannot race the limit check.
|
|
type twoFAAttemptState struct {
|
|
mu sync.Mutex
|
|
count int
|
|
lastAt time.Time
|
|
}
|
|
|
|
var (
|
|
twoFAAttemptMapMu sync.Mutex
|
|
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
|
|
)
|
|
|
|
// twoFAAttemptStateFor returns the per-user attempt state, creating it if
|
|
// needed. The map is bounded: stale entries are evicted opportunistically and,
|
|
// when at capacity, the least-recently-active entry is dropped.
|
|
func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
|
|
twoFAAttemptMapMu.Lock()
|
|
defer twoFAAttemptMapMu.Unlock()
|
|
now := clock.Now()
|
|
|
|
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts {
|
|
var oldestID string
|
|
var oldestAt time.Time
|
|
for id, st := range twoFAAttemptMap {
|
|
if now.Sub(st.lastAt) > twoFAAttemptWindow {
|
|
delete(twoFAAttemptMap, id)
|
|
continue
|
|
}
|
|
if oldestID == "" || st.lastAt.Before(oldestAt) {
|
|
oldestID, oldestAt = id, st.lastAt
|
|
}
|
|
}
|
|
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts && oldestID != "" {
|
|
delete(twoFAAttemptMap, oldestID)
|
|
}
|
|
}
|
|
|
|
st := twoFAAttemptMap[userID]
|
|
if st == nil {
|
|
st = &twoFAAttemptState{lastAt: now}
|
|
twoFAAttemptMap[userID] = st
|
|
}
|
|
return st
|
|
}
|
|
|
|
// twoFAResetAttempts clears a user's attempt counter. Called on successful
|
|
// verify and when a fresh code is generated via setup.
|
|
func twoFAResetAttempts(userID string) {
|
|
twoFAAttemptMapMu.Lock()
|
|
delete(twoFAAttemptMap, userID)
|
|
twoFAAttemptMapMu.Unlock()
|
|
}
|
|
|
|
type TwoFAStatusResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
Method *string `json:"method"`
|
|
Required bool `json:"required"`
|
|
}
|
|
|
|
// GET /api/user/2fa/status
|
|
func GetTwoFAStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var enabled bool
|
|
var method sql.NullString
|
|
err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT two_factor_enabled, two_factor_method
|
|
FROM users
|
|
WHERE id = $1
|
|
`, userID).Scan(&enabled, &method)
|
|
if err != nil {
|
|
log.Printf("failed to fetch 2FA status for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := TwoFAStatusResponse{Enabled: enabled, Required: twoFARequired()}
|
|
if method.Valid {
|
|
resp.Method = &method.String
|
|
}
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
log.Printf("failed to encode 2FA status response: %v", err)
|
|
}
|
|
}
|
|
|
|
type TwoFASetupRequest struct {
|
|
Method string `json:"method"`
|
|
}
|
|
|
|
// POST /api/user/2fa/setup
|
|
// Generates a verification code and stores only its SHA-256 hash plus a
|
|
// 10-minute expiry in the pending columns. The code itself is delivered by
|
|
// logging it with a [2FA] prefix — a loose fake for the not-yet-wired email/SMS
|
|
// transport. When 2FA is not enforced (dev), the code is also returned in the
|
|
// response so the flow is testable without reading backend logs.
|
|
func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req TwoFASetupRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Method != "email" && req.Method != "sms" {
|
|
http.Error(w, "method must be 'email' or 'sms'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var enabled bool
|
|
err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
|
if err != nil {
|
|
log.Printf("failed to check 2FA state for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if enabled {
|
|
http.Error(w, "Two-factor authentication is already enabled", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
code, err := generateTwoFACode()
|
|
if err != nil {
|
|
log.Printf("failed to generate 2FA code for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
expires := clock.Now().Add(twoFAPendingExpiry)
|
|
_, err = db.Conn.Exec(r.Context(), `
|
|
UPDATE users
|
|
SET two_factor_method = $2,
|
|
two_factor_pending_code_hash = $3,
|
|
two_factor_pending_code_expires = $4
|
|
WHERE id = $1
|
|
`, userID, req.Method, hashTwoFACode(code), expires)
|
|
if err != nil {
|
|
log.Printf("failed to store 2FA pending code for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// A fresh code invalidates any prior lockout state.
|
|
twoFAResetAttempts(userID)
|
|
|
|
if !twoFARequired() {
|
|
// Dev-only convenience: unenforced environments log the plaintext code
|
|
// (loose fake delivery). NEVER log it when enforced — a production
|
|
// misconfiguration must not leak verification codes to stdout.
|
|
log.Printf("[2FA] verification code for user %s (%s): %s", userID, req.Method, code)
|
|
} else {
|
|
log.Printf("[2FA] 2FA code generated for user %s (delivery channel: %s — NOT SENT, fake delivery)", userID, req.Method)
|
|
}
|
|
|
|
resp := map[string]any{"message": "Code sent"}
|
|
if !twoFARequired() {
|
|
// Dev convenience: unenforced environments return the code so the
|
|
// fake-delivery flow is usable without grepping the backend log.
|
|
resp["code"] = code
|
|
}
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
log.Printf("failed to encode 2FA setup response: %v", err)
|
|
}
|
|
}
|
|
|
|
type TwoFAVerifyRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// POST /api/user/2fa/verify
|
|
// Confirms the pending code (SHA-256, timing-safe, not expired) and flips
|
|
// two_factor_enabled on. When 2FA is not enforced (dev) any code — including an
|
|
// empty one — verifies, so local testing never depends on reading the logged
|
|
// code.
|
|
func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req TwoFAVerifyRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !twoFARequired() {
|
|
// Dev bypass: no code verification in unenforced environments.
|
|
if err := enableTwoFA(r, userID); err != nil {
|
|
log.Printf("failed to enable 2FA for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeTwoFAEnabled(w)
|
|
return
|
|
}
|
|
|
|
// Enforced path — brute-force resistant. The attempt counter is per-user and
|
|
// in-memory (no schema change); after 5 consecutive failures the pending
|
|
// code is invalidated and further attempts get 429 until a new code is
|
|
// requested via setup. The per-user mutex serializes the critical section so
|
|
// concurrent attempts cannot race the limit.
|
|
st := twoFAAttemptStateFor(userID)
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow {
|
|
st.count = 0
|
|
st.lastAt = now
|
|
}
|
|
if st.count >= twoFAMaxAttempts {
|
|
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
var pendingHash sql.NullString
|
|
var pendingExpires sql.NullTime
|
|
err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
|
FROM users
|
|
WHERE id = $1
|
|
`, userID).Scan(&pendingHash, &pendingExpires)
|
|
if err != nil {
|
|
log.Printf("failed to fetch 2FA pending code for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
|
|
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Constant-time compare (subtle) so a wrong code's match position cannot be
|
|
// inferred from response timing. Both digests are fixed-length hex.
|
|
if subtle.ConstantTimeCompare([]byte(hashTwoFACode(req.Code)), []byte(pendingHash.String)) != 1 {
|
|
st.count++
|
|
st.lastAt = clock.Now()
|
|
if st.count >= twoFAMaxAttempts {
|
|
// Lockout reached: destroy the pending code so a stolen digest
|
|
// cannot be replayed against a fresh guessing loop.
|
|
if _, err := db.Conn.Exec(r.Context(), `
|
|
UPDATE users
|
|
SET two_factor_pending_code_hash = NULL,
|
|
two_factor_pending_code_expires = NULL
|
|
WHERE id = $1
|
|
`, userID); err != nil {
|
|
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
|
|
}
|
|
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Success: clear the attempt counter before enabling 2FA.
|
|
st.count = 0
|
|
st.lastAt = clock.Now()
|
|
twoFAResetAttempts(userID)
|
|
|
|
if err := enableTwoFA(r, userID); err != nil {
|
|
log.Printf("failed to enable 2FA for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeTwoFAEnabled(w)
|
|
}
|
|
|
|
// enableTwoFA persists two_factor_enabled=true and clears the pending code
|
|
// fields (the method was set during setup).
|
|
func enableTwoFA(r *http.Request, userID string) error {
|
|
_, err := db.Conn.Exec(r.Context(), `
|
|
UPDATE users
|
|
SET two_factor_enabled = true,
|
|
two_factor_pending_code_hash = NULL,
|
|
two_factor_pending_code_expires = NULL
|
|
WHERE id = $1
|
|
`, userID)
|
|
return err
|
|
}
|
|
|
|
func writeTwoFAEnabled(w http.ResponseWriter) {
|
|
if err := json.NewEncoder(w).Encode(map[string]bool{"enabled": true}); err != nil {
|
|
log.Printf("failed to encode 2FA verify response: %v", err)
|
|
}
|
|
}
|
|
|
|
type TwoFADisableRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// POST /api/user/2fa/disable
|
|
// Turns 2FA off and clears method + pending fields for the authenticated user.
|
|
// The code field is accepted but ignored — a documented loose-fake simplification
|
|
// until the real SCA flow requires re-authentication to disable.
|
|
func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Body is optional; decode leniently so an empty body disables cleanly.
|
|
var req TwoFADisableRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
_, err := db.Conn.Exec(r.Context(), `
|
|
UPDATE users
|
|
SET two_factor_enabled = false,
|
|
two_factor_method = NULL,
|
|
two_factor_pending_code_hash = NULL,
|
|
two_factor_pending_code_expires = NULL
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|