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 }