Fresh-eyes review round with 6 independent agents (money-safety, concurrency, Square wire parity, security, frontend flow, testing-gaps). Every finding was independently verified against the code before fixing. All backend changes now carry full test suites (10+ new tests, each verified to FAIL without its guard). All 20 packages green, race detector clean. Money-safety: - Gift-card purchase refunds no longer create money: manual refunds of a no-booking (gift-card purchase) payment are rejected with a clear message in the direct handler AND never re-issued by the sweep-resume path (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue). - BuyGiftCard no-client-key fallback: derived deterministically under the advisory lock (pending-row reuse fixes lost-response double-charge; completed-row sequence advance preserves distinct-purchase collapse fix). - Terminal completion is never unrecorded: activeTerminalCheckoutID now calls recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found COMPLETED at Square (previously only marked the row COMPLETED — a lost poll left the payment invisible and unrefundable). - Sweep: provisional tmp- checkout rows are resolved against Square first (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail; ambiguous → leave pending) instead of blind-failing a possibly-live checkout. recordUntrackedTerminalPayment re-checks the booking status (FOR UPDATE) and refuses to record on a cancelled booking, inserting a critical_payment_log admin notification instead. Till-sale post-charge UPDATE now requires status='pending' (no resurrection of a clawed-back sale). Frontend (Svelte 5): - UserPaymentModal keeps CardSelection mounted through processing (bind:this ref + Square iframe survive the loyalty/tokenize awaits) — new-card payments work again. - BookingFlow clears the cached nonce/verification pair on any failure (retry re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid' refetches the booking and reconciles depositPaid so the confirmation gate opens; Back button disabled during processing. - Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip. Square wire parity (mock vs real): - processing_fee sign unified (negated at paymentFromSquare; mock agrees). - SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard. - GetCardsOnFile excludes disabled cards (matches ListCards). - ForcePaymentStatus toggle + tests prove the charge path can't be status-blind. - CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID); completed terminal checkout's payment resolvable by id. Security: - 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction scan reads race-free; concurrent verify+evict tests under -race. - Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or known public placeholders) with openssl rand -hex 32 guidance. - Dockerfile no longer COPYs .env (secrets injected via compose env_file). - SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose fails at config time when missing. Testing gaps closed (each verified to FAIL without its guard): - refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown in both by-key and by-id paths), resolveChargeSource Square-failure branches, structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending), deriveBookingPaymentIdempotencyKey >45-char truncation, webhook findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch, dispute.evidence / terminal.checkout dispatch. Infra: - local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures (previously died silently under ERR_EXIT with hidden output). - Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go gained the missing build tag. Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok), -race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose config valid.
717 lines
27 KiB
Go
717 lines
27 KiB
Go
package user
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"math/big"
|
|
"net/http"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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
|
|
}
|
|
|
|
// twoFAPepperEnv is the environment variable carrying the server-side pepper
|
|
// that keys the HMAC of stored 2FA codes (documented in .env.example). When it
|
|
// is absent the code falls back to the legacy plain SHA-256 digest with a
|
|
// one-time warning — see hashTwoFACode.
|
|
const twoFAPepperEnv = "TWO_FACTOR_PEPPER"
|
|
|
|
// twoFAPepperWarnOnce guards the one-time warning when TWO_FACTOR_PEPPER is
|
|
// unset, so a misconfigured deployment is loudly flagged once rather than on
|
|
// every code operation.
|
|
var twoFAPepperWarnOnce sync.Once
|
|
|
|
// twoFAPepper returns the configured HMAC pepper, or "" when unset. Read per
|
|
// call (the rest of the backend reads env vars per call too) so a value
|
|
// provisioned at runtime is picked up; only the warning is gated on sync.Once.
|
|
func twoFAPepper() string {
|
|
pepper := os.Getenv(twoFAPepperEnv)
|
|
if pepper == "" {
|
|
twoFAPepperWarnOnce.Do(func() {
|
|
log.Printf("WARNING: TWO_FACTOR_PEPPER unset — 2FA codes hashed without an HMAC pepper (falling back to unsalted SHA-256); set TWO_FACTOR_PEPPER in production so a leaked digest cannot be brute-forced offline")
|
|
})
|
|
}
|
|
return pepper
|
|
}
|
|
|
|
// hashTwoFACode returns the hex digest of a verification code as stored in the
|
|
// DB. With TWO_FACTOR_PEPPER set the digest is HMAC-SHA256 keyed by the pepper,
|
|
// so a leaked digest cannot be brute-forced offline (the key stays server-side).
|
|
// When the pepper is unset it falls back to the legacy unsalted SHA-256 digest
|
|
// and logs a one-time warning. The plaintext code is never stored — only
|
|
// delivered via the [2FA] log line (see deliverTwoFACode).
|
|
func hashTwoFACode(code string) string {
|
|
if pepper := twoFAPepper(); pepper != "" {
|
|
mac := hmac.New(sha256.New, []byte(pepper))
|
|
mac.Write([]byte(code))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
sum := sha256.Sum256([]byte(code))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest, used to
|
|
// verify rows written before TWO_FACTOR_PEPPER was provisioned during the
|
|
// migration window (see verifyTwoFACodeHash).
|
|
func legacyHashTwoFACode(code string) string {
|
|
sum := sha256.Sum256([]byte(code))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
|
|
// digest, always in constant time (subtle.ConstantTimeCompare). The first
|
|
// comparison uses the current pepper'd digest; when that fails the stored hash
|
|
// may be a legacy pre-pepper plain SHA-256 (rows written before TWO_FACTOR_PEPPER
|
|
// was provisioned), so the legacy digest is tried too. When a legacy row
|
|
// matches, legacy is true and the caller should re-hash with the pepper on the
|
|
// next successful verify, retiring the plain digest.
|
|
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
|
|
if subtle.ConstantTimeCompare([]byte(hashTwoFACode(reqCode)), []byte(storedHash)) == 1 {
|
|
return true, false
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(legacyHashTwoFACode(reqCode)), []byte(storedHash)) == 1 {
|
|
return true, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// 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.
|
|
// Declared as a var so the eviction policy is unit-testable at a small cap.
|
|
var 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. count and lastAt are
|
|
// atomic so the map eviction path can read them without taking the per-user
|
|
// mutex (lock ordering forbids mapMu→st.mu: checkTwoFACode holds st.mu then
|
|
// takes mapMu). lastAt is stored as nanoseconds since the Unix epoch so the
|
|
// eviction scan and lockedOut read it race-free even on 32-bit platforms — a
|
|
// plain time.Time read/write pair there could tear the 8-byte timestamp and
|
|
// reset or extend the lockout window.
|
|
// lastMintAt is the disable-flow mint cooldown stamp (see twoFAMintCooldown);
|
|
// it is only ever touched under st.mu.
|
|
type twoFAAttemptState struct {
|
|
mu sync.Mutex
|
|
count atomic.Int32
|
|
lastAt atomic.Int64
|
|
lastMintAt time.Time
|
|
}
|
|
|
|
// lastActive returns the state's last-activity timestamp (nanoseconds since the
|
|
// Unix epoch, UTC). Reads are atomic so the map eviction scan can call it while
|
|
// holding only mapMu.
|
|
func (st *twoFAAttemptState) lastActive() time.Time {
|
|
return time.Unix(0, st.lastAt.Load()).UTC()
|
|
}
|
|
|
|
// setLastActive records a last-activity timestamp. Writes happen under st.mu
|
|
// (checkTwoFACode) while the eviction scan reads under mapMu only — the atomic
|
|
// store makes both race-free.
|
|
func (st *twoFAAttemptState) setLastActive(t time.Time) {
|
|
st.lastAt.Store(t.UnixNano())
|
|
}
|
|
|
|
// lockedOut reports whether the state is inside its lockout window: the attempt
|
|
// counter has reached the cap and the window has not yet elapsed. Such a record
|
|
// is the rate limit's source of truth for its user and must never be evicted
|
|
// while in-window — evicting it would silently reset the counter and grant a
|
|
// fresh guessing budget.
|
|
func (st *twoFAAttemptState) lockedOut(now time.Time) bool {
|
|
return st.count.Load() >= twoFAMaxAttempts && now.Sub(st.lastActive()) <= twoFAAttemptWindow
|
|
}
|
|
|
|
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 (window-expired) entries are evicted
|
|
// opportunistically and, when at capacity, the least-recently-active
|
|
// non-locked-out entry is dropped. A record still inside its lockout window is
|
|
// NEVER evicted — evicting it would reset the victim's attempt counter and
|
|
// bypass the rate limit under a hostile flood of new keys. When the map is
|
|
// full of in-window locked-out records (a pathological flood), a transient,
|
|
// untracked state is returned instead of growing the map past the cap.
|
|
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.lastActive()) > twoFAAttemptWindow {
|
|
// Idle/expired — its counter has already lapsed; safe to evict.
|
|
delete(twoFAAttemptMap, id)
|
|
continue
|
|
}
|
|
if st.lockedOut(now) {
|
|
// Inside its lockout window — the rate limit's source of truth
|
|
// for this user. Never evict (finding-e fix).
|
|
continue
|
|
}
|
|
if at := st.lastActive(); oldestID == "" || at.Before(oldestAt) {
|
|
oldestID, oldestAt = id, at
|
|
}
|
|
}
|
|
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts && oldestID != "" {
|
|
delete(twoFAAttemptMap, oldestID)
|
|
}
|
|
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts {
|
|
// Every entry is a locked-out in-window record. Do not evict one
|
|
// (that would reset its rate limit) and do not grow past the cap:
|
|
// return a transient, untracked state so THIS request still
|
|
// proceeds under a fresh budget.
|
|
st := &twoFAAttemptState{}
|
|
st.setLastActive(now)
|
|
return st
|
|
}
|
|
}
|
|
|
|
st := twoFAAttemptMap[userID]
|
|
if st == nil {
|
|
st = &twoFAAttemptState{}
|
|
st.setLastActive(now)
|
|
twoFAAttemptMap[userID] = st
|
|
}
|
|
return st
|
|
}
|
|
|
|
// twoFAResetAttempts resets a user's attempt counter in place (count only)
|
|
// WITHOUT deleting the entry, preserving lastMintAt so the disable-flow mint
|
|
// cooldown survives a fresh-code delivery. Called on successful verify and when
|
|
// a fresh code is generated via setup or disable. lastAt is deliberately not
|
|
// touched here: it is re-stamped by checkTwoFACode on real activity, and
|
|
// writing it under mapMu would race with checkTwoFACode's st.mu-guarded write
|
|
// (the setup path holds no st.mu). The lock ordering is st.mu→mapMu at call
|
|
// sites, never the reverse (twoFAAttemptStateFor takes mapMu only and never
|
|
// takes st.mu).
|
|
func twoFAResetAttempts(userID string) {
|
|
twoFAAttemptMapMu.Lock()
|
|
defer twoFAAttemptMapMu.Unlock()
|
|
if st := twoFAAttemptMap[userID]; st != nil {
|
|
st.count.Store(0)
|
|
}
|
|
}
|
|
|
|
// deliverTwoFACode generates a fresh verification code, persists only its
|
|
// SHA-256 hash plus the pending expiry (updating two_factor_method when method
|
|
// is non-empty), resets any prior lockout, and logs the plaintext code.
|
|
//
|
|
// The [2FA] log line is the delivery channel — the loose-fake stand-in for the
|
|
// not-yet-wired email/SMS transport (P6). The plaintext code is ALWAYS logged,
|
|
// enforced and unenforced alike: in enforced (production) environments the
|
|
// server log is the only way a code can reach the user, so an operator must
|
|
// relay it out-of-band. Do not gate this log line on the environment — without
|
|
// it, enforced-mode 2FA has no delivery path at all and every online saved-card
|
|
// charge stays 403. The API response still only returns the code when 2FA is
|
|
// unenforced (dev convenience). purpose labels the log line (e.g. "setup",
|
|
// "disable 2FA").
|
|
func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string, error) {
|
|
code, err := generateTwoFACode()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
expires := clock.Now().Add(twoFAPendingExpiry)
|
|
if method != "" {
|
|
_, 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, method, hashTwoFACode(code), expires)
|
|
} else {
|
|
_, err = db.Conn.Exec(r.Context(), `
|
|
UPDATE users
|
|
SET two_factor_pending_code_hash = $2,
|
|
two_factor_pending_code_expires = $3
|
|
WHERE id = $1
|
|
`, userID, hashTwoFACode(code), expires)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// A fresh code invalidates any prior lockout state.
|
|
twoFAResetAttempts(userID)
|
|
|
|
label := method
|
|
if label == "" {
|
|
label = purpose
|
|
}
|
|
log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code)
|
|
return code, nil
|
|
}
|
|
|
|
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 is delivered by logging it
|
|
// with a [2FA] prefix — the loose-fake stand-in for the not-yet-wired email/SMS
|
|
// transport (P6). The plaintext code is ALWAYS logged, enforced and unenforced
|
|
// alike: in enforced (production) environments the server log is the only
|
|
// delivery channel, so an operator must relay the code to the user out-of-band.
|
|
// 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
|
|
}
|
|
|
|
// Deliver a fresh code via the shared setup mechanism: generate, persist
|
|
// only the hash + expiry, reset any prior lockout, and log the plaintext
|
|
// code (the [2FA] log channel — see deliverTwoFACode).
|
|
code, err := deliverTwoFACode(r, userID, req.Method, "setup")
|
|
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
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// twoFACodeCheckResult classifies checkTwoFACode's outcome so callers can map
|
|
// it to the correct HTTP status.
|
|
type twoFACodeCheckResult int
|
|
|
|
const (
|
|
twoFACodeOK twoFACodeCheckResult = iota
|
|
twoFACodeIncorrect
|
|
twoFACodeLockedOut
|
|
twoFACodeMissingOrExpired
|
|
)
|
|
|
|
// checkTwoFACode verifies the submitted code against the user's stored pending
|
|
// code under the per-user brute-force lockout, shared by VerifyTwoFAHandler and
|
|
// DisableTwoFAHandler. The caller must hold st.mu (from twoFAAttemptStateFor)
|
|
// so concurrent attempts from the same user cannot race the limit check. A
|
|
// correct code resets the attempt counter and returns twoFACodeOK. An incorrect
|
|
// code increments the counter and, on the 5th consecutive failure, invalidates
|
|
// the pending code (lockout). A missing or expired pending code returns
|
|
// twoFACodeMissingOrExpired. 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 checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
|
|
if now := clock.Now(); now.Sub(st.lastActive()) > twoFAAttemptWindow {
|
|
st.count.Store(0)
|
|
st.setLastActive(now)
|
|
}
|
|
if st.count.Load() >= twoFAMaxAttempts {
|
|
return twoFACodeLockedOut, nil
|
|
}
|
|
|
|
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 {
|
|
return twoFACodeLockedOut, err
|
|
}
|
|
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
|
|
return twoFACodeMissingOrExpired, nil
|
|
}
|
|
// Constant-time compare (subtle) so a wrong code's match position cannot be
|
|
// inferred from response timing. Both digests are fixed-length hex. Legacy
|
|
// pre-pepper rows (plain SHA-256, hashed before TWO_FACTOR_PEPPER existed)
|
|
// still verify during the transition window.
|
|
match, legacy := verifyTwoFACodeHash(reqCode, pendingHash.String)
|
|
if !match {
|
|
st.count.Add(1)
|
|
st.setLastActive(clock.Now())
|
|
if st.count.Load() >= 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)
|
|
}
|
|
return twoFACodeLockedOut, nil
|
|
}
|
|
return twoFACodeIncorrect, nil
|
|
}
|
|
|
|
// Success: a legacy (pre-pepper) hash that verified is re-hashed with the
|
|
// pepper so the plain digest is retired on the next successful verify.
|
|
if legacy {
|
|
if _, err := db.Conn.Exec(r.Context(), `
|
|
UPDATE users
|
|
SET two_factor_pending_code_hash = $2
|
|
WHERE id = $1
|
|
`, userID, hashTwoFACode(reqCode)); err != nil {
|
|
log.Printf("failed to upgrade legacy 2FA pending code hash for user %s: %v", userID, err)
|
|
}
|
|
}
|
|
// Success: clear the attempt counter (and any disable-flow mint cooldown)
|
|
// before the caller performs its action.
|
|
st.count.Store(0)
|
|
st.setLastActive(clock.Now())
|
|
st.lastMintAt = time.Time{}
|
|
twoFAResetAttempts(userID)
|
|
return twoFACodeOK, nil
|
|
}
|
|
|
|
// 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 (see checkTwoFACode): the per-user
|
|
// mutex serializes the critical section so concurrent attempts cannot race
|
|
// the limit; after 5 consecutive failures the pending code is invalidated
|
|
// and further attempts get 429 until a new code is requested via setup.
|
|
st := twoFAAttemptStateFor(userID)
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
result, err := checkTwoFACode(r, userID, st, req.Code)
|
|
if err != nil {
|
|
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
switch result {
|
|
case twoFACodeIncorrect:
|
|
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
|
return
|
|
case twoFACodeLockedOut:
|
|
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
|
return
|
|
case twoFACodeMissingOrExpired:
|
|
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// twoFAMintCooldown bounds how often a fresh 2FA code may be minted for one
|
|
// user during the disable flow. Without it, a password-only attacker could loop
|
|
// disable → fresh code (which resets the 5-attempt counter) → 5 wrong guesses →
|
|
// fresh code again, for ~100 guesses/min unbounded. The cooldown caps guessing
|
|
// at 5 per window (~5/min) while still letting a legitimate code-lost user
|
|
// recover after a short wait.
|
|
const twoFAMintCooldown = 1 * time.Minute
|
|
|
|
// errTwoFAMintThrottled is returned by ensurePendingTwoFACode when the user's
|
|
// last disable-flow mint is inside twoFAMintCooldown, so the caller returns 429
|
|
// instead of minting another fresh code.
|
|
var errTwoFAMintThrottled = errors.New("2FA code mint throttled")
|
|
|
|
type TwoFADisableRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// POST /api/user/2fa/disable
|
|
// Turns 2FA off and clears method + pending fields for the authenticated user.
|
|
//
|
|
// Disabling 2FA lifts the SCA stand-in gate on saved-card charges, so in
|
|
// enforced environments a verification code is required — a password-only
|
|
// attacker must not be able to disable the protection. A fresh code is generated
|
|
// and delivered via the [2FA] log channel when no valid pending code exists, and
|
|
// the submitted code is checked under the shared 5-attempt lockout (wrong code →
|
|
// 400, lockout → 429); only a correct code clears the flag. Fresh-code mints are
|
|
// throttled per-user (twoFAMintCooldown) so the loop above cannot reset the
|
|
// lockout faster than once per cooldown. In unenforced (dev) environments the
|
|
// loose behavior is kept: no code required, so local dev is not blocked.
|
|
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 still works in
|
|
// unenforced (dev) environments.
|
|
var req TwoFADisableRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
if !twoFARequired() {
|
|
// Dev bypass: no re-verification in unenforced environments.
|
|
if err := disableTwoFA(r, userID); 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)
|
|
return
|
|
}
|
|
|
|
// Enforced path. The per-user mutex serializes the whole critical section
|
|
// (fresh-code generation + code check) so concurrent requests cannot race
|
|
// the lockout counter.
|
|
st := twoFAAttemptStateFor(userID)
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
// Reuse a valid pending code when one exists; otherwise generate + deliver
|
|
// a fresh one via the same [2FA] log channel as setup. A fresh code gets its
|
|
// own independent 5-attempt budget (the mint resets the counter), so the
|
|
// per-user mint cooldown is what stops the unlimited-guess loop — an
|
|
// attacker can mint at most one fresh code per twoFAMintCooldown.
|
|
if err := ensurePendingTwoFACode(r, userID, st); err != nil {
|
|
if errors.Is(err, errTwoFAMintThrottled) {
|
|
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
result, err := checkTwoFACode(r, userID, st, req.Code)
|
|
if err != nil {
|
|
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
switch result {
|
|
case twoFACodeIncorrect:
|
|
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
|
return
|
|
case twoFACodeLockedOut:
|
|
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
|
return
|
|
case twoFACodeMissingOrExpired:
|
|
// ensurePendingTwoFACode just guaranteed a valid pending code; defensive.
|
|
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := disableTwoFA(r, userID); 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)
|
|
}
|
|
|
|
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
|
|
// code to verify against, generating + delivering a fresh one via the same
|
|
// [2FA] log channel as setup when the stored code is missing or expired. The
|
|
// caller must hold the user's attempt-state mutex.
|
|
//
|
|
// A fresh code gets its own independent 5-attempt budget (deliverTwoFACode
|
|
// resets the counter via twoFAResetAttempts), so the per-user mint cooldown is
|
|
// what prevents a password-only attacker from looping mint → burn 5 guesses →
|
|
// mint forever: only one fresh code per twoFAMintCooldown per user. A locked-out
|
|
// user can still use the code minted in THIS request; a user who exhausts it
|
|
// must wait out the cooldown for the next mint — the documented disable-flow
|
|
// residual. A failed delivery does not start the cooldown (the stamp is written
|
|
// only after the UPDATE persisted).
|
|
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState) error {
|
|
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 {
|
|
return err
|
|
}
|
|
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
|
|
return nil
|
|
}
|
|
now := clock.Now()
|
|
if !st.lastMintAt.IsZero() && now.Sub(st.lastMintAt) < twoFAMintCooldown {
|
|
return errTwoFAMintThrottled
|
|
}
|
|
if _, err := deliverTwoFACode(r, userID, "", "disable 2FA"); err != nil {
|
|
return err
|
|
}
|
|
st.lastMintAt = now
|
|
return nil
|
|
}
|
|
|
|
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
|
|
func disableTwoFA(r *http.Request, userID string) error {
|
|
_, 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)
|
|
return err
|
|
}
|