- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak - verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle - sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD - delete-account re-auth (current_password + fresh 2FA code when enforced) - prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check - startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals - env-docs parity updated (flag removed, 38 vars)
1140 lines
43 KiB
Go
1140 lines
43 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"crussell/auth"
|
||
"crussell/clock"
|
||
"crussell/db"
|
||
"crussell/internal/dav"
|
||
"crussell/internal/twofa"
|
||
"crussell/internal/validators"
|
||
"crussell/internal/zxcvbnjs"
|
||
"crussell/mw"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"net/http"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5/middleware"
|
||
|
||
"github.com/nyaruka/phonenumbers"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"golang.org/x/text/cases"
|
||
"golang.org/x/text/language"
|
||
)
|
||
|
||
// maxLoginInProgress caps the loginInProgress map (Round 2 Loop A finding 5):
|
||
// at most this many logins may be mid-flight at once before the next is
|
||
// rejected 429. Stale entries are evicted before the cap is consulted (see
|
||
// evictStaleLoginEntriesLocked), so a single attacker holding N fake entries
|
||
// cannot permanently exhaust the global budget — only genuinely concurrent
|
||
// logins occupy it, and each entry self-releases via the handler's deferred
|
||
// delete.
|
||
const maxLoginInProgress = 20
|
||
|
||
// loginInProgressWindow is how long a loginInProgress entry is considered
|
||
// live before it is stale and evictable.
|
||
const loginInProgressWindow = 30 * time.Second
|
||
|
||
// maxConcurrentBcrypt bounds how many bcrypt operations may run concurrently
|
||
// across BOTH /login and /register (Round 2 Loop A finding 4b + Round 2 Loop B
|
||
// finding 4). The progressive per-IP middleware sleeps BEFORE this handler, so
|
||
// without the cap a flood of throttled requests could stack an unbounded number
|
||
// of goroutines that all hit bcrypt the moment their sleeps elapse — a
|
||
// CPU-amplification vector (a register-botnet also burns CPU on
|
||
// bcrypt.GenerateFromPassword). Beyond the cap the request is rejected 429
|
||
// immediately (nothing has been processed, so nothing leaks).
|
||
//
|
||
// Round 2 Loop B finding 5b — ACCEPTED BOUNDED-DoS TRADE-OFF: the 20-slot
|
||
// global bound is shared by login AND register AND, by extension, every
|
||
// authenticated user. A sustained flood at either endpoint can therefore
|
||
// starve bcrypt for everyone for up to one request at a time (429 "server
|
||
// busy"). That is the intended trade-off: 20 genuinely concurrent bcrypt
|
||
// operations (~20 × ~60ms ≈ 1.2s of wall time) is far more than a single
|
||
// salon ever produces, and bounding the CPU is the point of the cap.
|
||
const maxConcurrentBcrypt = 20
|
||
|
||
// Login state management
|
||
var (
|
||
loginStateMu sync.Mutex
|
||
loginInProgress = make(map[string]time.Time)
|
||
// authBcryptSlots is the counting semaphore backing maxConcurrentBcrypt,
|
||
// shared by LoginHandler and RegisterHandler.
|
||
authBcryptSlots = make(chan struct{}, maxConcurrentBcrypt)
|
||
)
|
||
|
||
// acquireBcryptSlot tries to reserve a concurrent bcrypt slot. ok=false
|
||
// means the handler must respond 429. Shared by login and register so the
|
||
// bcrypt CPU budget is global, not per-endpoint.
|
||
func acquireBcryptSlot() (release func(), ok bool) {
|
||
select {
|
||
case authBcryptSlots <- struct{}{}:
|
||
return func() { <-authBcryptSlots }, true
|
||
default:
|
||
return nil, false
|
||
}
|
||
}
|
||
|
||
// evictStaleLoginEntriesLocked removes loginInProgress entries older than
|
||
// loginInProgressWindow. Caller must hold loginStateMu.
|
||
func evictStaleLoginEntriesLocked(now time.Time) {
|
||
for userID, startedAt := range loginInProgress {
|
||
if now.Sub(startedAt) > loginInProgressWindow {
|
||
delete(loginInProgress, userID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// CleanupStaleLoginEntries removes stuck loginInProgress entries older than
|
||
// loginInProgressWindow. Called by the centralised jobs scheduler.
|
||
func CleanupStaleLoginEntries(ctx context.Context) (int, error) {
|
||
loginStateMu.Lock()
|
||
defer loginStateMu.Unlock()
|
||
evictStaleLoginEntriesLocked(clock.Now())
|
||
return 0, nil
|
||
}
|
||
|
||
// dummyPasswordHash is a real bcrypt hash of a fixed throwaway string, used to
|
||
// burn the same constant-time bcrypt work on the login no-user path as a real
|
||
// wrong-password compare (see LoginHandler). It MUST be a well-formed bcrypt
|
||
// hash: CompareHashAndPassword on a malformed hash returns immediately (fast),
|
||
// which would reintroduce the timing oracle it exists to remove.
|
||
const dummyPasswordHash = "$2a$10$x9x4AEOAU.UbaGCmVsVwu.TUhfOfR2LfbmWjB/H2At8Sx69WIlkri"
|
||
|
||
// Verification-code purpose values (verification_purpose enum in init-script.sql).
|
||
const (
|
||
verificationCodePurposeEmailVerify = "email_verify"
|
||
verificationCodePurposePasswordReset = "password_reset"
|
||
)
|
||
|
||
// verificationCodePepperEnv is the environment variable whose value keys the
|
||
// HMAC-SHA256 of stored verification codes (the SAME pepper the 2FA path uses —
|
||
// crussell/internal/twofa Hash). Read through the build-tagged
|
||
// verificationCodeEnsureIssueAllowed (verifycode_dev.go / verifycode_prod.go):
|
||
// dev/test builds fall back to the legacy plain SHA-256 digest with the 2FA
|
||
// warning, while production builds refuse to issue codes without the pepper.
|
||
const verificationCodePepperEnv = "TWO_FACTOR_PEPPER"
|
||
|
||
// Email-verification attempt budget (Round 2 Loop A finding 8 + hardening):
|
||
// POST /verify/check had no per-user attempt counter, so a client holding a
|
||
// code could fail it indefinitely and the endpoint doubled as an unbounded
|
||
// guessing oracle. Mirror the 2FA attempt pattern: an in-memory map keys a
|
||
// 5-attempt budget. The key is the RESOLVED USER id whenever a submitted code
|
||
// matches a verification_codes row (a code belongs to exactly one user, so the
|
||
// budget follows the ACCOUNT being attacked, not the submitted code value) and
|
||
// the submitted code value only when no row exists to resolve a user (a pure
|
||
// guess cannot be attributed). Keying per-user closes the evasion where an
|
||
// attacker holding several codes for one victim (or probing which values are
|
||
// live) drained a fresh budget per code. A successful verify clears the entry;
|
||
// the 5th failed attempt exhausts the budget (429). The map is bounded and
|
||
// stale entries are evicted, so a flood of random guesses cannot grow it
|
||
// without bound.
|
||
const (
|
||
emailVerifyMaxAttempts = 5
|
||
emailVerifyAttemptWindow = 30 * time.Minute
|
||
emailVerifyMaxTrackedCodes = 10_000
|
||
)
|
||
|
||
type emailVerifyAttempt struct {
|
||
count int
|
||
lastAt time.Time
|
||
}
|
||
|
||
var (
|
||
emailVerifyMu sync.Mutex
|
||
emailVerifyAttempts = make(map[string]emailVerifyAttempt)
|
||
)
|
||
|
||
// emailVerifyAttemptsExhausted reports whether the key's (a user id, or a
|
||
// submitted code with no resolvable user) attempt budget is already spent,
|
||
// rejecting the request before any DB work.
|
||
func emailVerifyAttemptsExhausted(key string) bool {
|
||
emailVerifyMu.Lock()
|
||
defer emailVerifyMu.Unlock()
|
||
evictStaleEmailVerifyAttemptsLocked()
|
||
a, ok := emailVerifyAttempts[key]
|
||
return ok && a.count >= emailVerifyMaxAttempts
|
||
}
|
||
|
||
// emailVerifyAttemptFailed registers one failed verification attempt for the
|
||
// key and reports whether the budget for that key is now exhausted (the handler
|
||
// should respond 429).
|
||
func emailVerifyAttemptFailed(key string) bool {
|
||
emailVerifyMu.Lock()
|
||
defer emailVerifyMu.Unlock()
|
||
evictStaleEmailVerifyAttemptsLocked()
|
||
now := clock.Now()
|
||
a := emailVerifyAttempts[key]
|
||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||
a.count = 0
|
||
}
|
||
a.count++
|
||
a.lastAt = now
|
||
emailVerifyAttempts[key] = a
|
||
return a.count >= emailVerifyMaxAttempts
|
||
}
|
||
|
||
// emailVerifyAttemptsClear drops the budget for a key after a successful
|
||
// verify (the code is consumed; the entry would only leak stale state).
|
||
func emailVerifyAttemptsClear(key string) {
|
||
emailVerifyMu.Lock()
|
||
delete(emailVerifyAttempts, key)
|
||
emailVerifyMu.Unlock()
|
||
}
|
||
|
||
// evictStaleEmailVerifyAttemptsLocked bounds the attempts map. Caller must
|
||
// hold emailVerifyMu.
|
||
func evictStaleEmailVerifyAttemptsLocked() {
|
||
if len(emailVerifyAttempts) < emailVerifyMaxTrackedCodes {
|
||
return
|
||
}
|
||
now := clock.Now()
|
||
for k, a := range emailVerifyAttempts {
|
||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||
delete(emailVerifyAttempts, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
type RegisterRequest struct {
|
||
FirstName string `json:"firstName" validate:"required,min=1,max=50"`
|
||
LastName string `json:"lastName" validate:"required,min=1,max=50"`
|
||
Email string `json:"email" validate:"required,email,max=254"`
|
||
Password string `json:"password" validate:"required,min=6,max=72"`
|
||
Phone string `json:"phone" validate:"required"`
|
||
DateOfBirth string `json:"dateOfBirth" validate:"required"`
|
||
AgreedToPolicy bool `json:"agreedToPolicy"`
|
||
ReferralCode string `json:"referralCode,omitempty" validate:"omitempty,max=12"`
|
||
}
|
||
|
||
type LoginRequest struct {
|
||
Email string `json:"email" validate:"required,email,max=254"`
|
||
Password string `json:"password" validate:"required,max=72"`
|
||
}
|
||
|
||
// POST /api/register
|
||
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req RegisterRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if err := validators.Validate.Struct(&req); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Must accept terms
|
||
if !req.AgreedToPolicy {
|
||
mw.RespondError(w, http.StatusBadRequest, "must agree to terms")
|
||
return
|
||
}
|
||
|
||
// Normalize input
|
||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||
req.FirstName = strings.TrimSpace(req.FirstName)
|
||
req.LastName = strings.TrimSpace(req.LastName)
|
||
req.Phone = strings.TrimSpace(req.Phone)
|
||
req.DateOfBirth = strings.TrimSpace(req.DateOfBirth)
|
||
|
||
// Check required fields
|
||
if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" || req.DateOfBirth == "" || req.Password == "" {
|
||
http.Error(w, "all fields are required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Password must not exceed bcrypt's 72-byte limit
|
||
if len(req.Password) > 72 {
|
||
mw.RespondError(w, http.StatusBadRequest, "password must be 72 characters or less")
|
||
return
|
||
}
|
||
if len(req.Password) < 6 {
|
||
mw.RespondError(w, http.StatusBadRequest, "password must be at least 6 characters")
|
||
return
|
||
}
|
||
// Round 2 Loop B finding 4: /register previously ran the zxcvbn strength
|
||
// scoring AND bcrypt.GenerateFromPassword with NO concurrency cap — a
|
||
// register-botnet could stack unbounded goroutines burning CPU, and each
|
||
// registered account also fuels the notification-flood and attempt-map
|
||
// findings (1/3). Share the login bcrypt slot budget (acquireBcryptSlot —
|
||
// the global 20-slot cap, see maxConcurrentBcrypt): beyond it the
|
||
// registration is rejected 429 immediately. The slot wraps the expensive
|
||
// part (zxcvbn + bcrypt) and is released via defer on every path.
|
||
release, ok := acquireBcryptSlot()
|
||
if !ok {
|
||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||
return
|
||
}
|
||
defer release()
|
||
|
||
// Server-side password strength check using the same @zxcvbn-ts/core as the frontend
|
||
// via goja (ExecJS-style). Guarantees exact parity with frontend scoring.
|
||
// Skipped when GO_TESTING=1 (dev/test environments) to allow weaker passwords.
|
||
if os.Getenv("GO_TESTING") != "1" {
|
||
passwordStrength, err := zxcvbnjs.Score(req.Password)
|
||
if err != nil {
|
||
log.Printf("Password strength check failed: %v", err)
|
||
http.Error(w, "password is too weak. please choose a stronger password.", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if passwordStrength < 2 {
|
||
http.Error(w, "password is too weak. please choose a stronger password.", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
// Validate name (unicode letters, spaces, hyphen, apostrophe, dot)
|
||
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
|
||
|
||
if !nameRegex.MatchString(req.FirstName) {
|
||
http.Error(w, "invalid characters in name", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Validate length
|
||
if len(req.FirstName) > 50 || len(req.FirstName) < 1 {
|
||
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if len(req.LastName) > 50 || len(req.LastName) < 1 {
|
||
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Validate email format
|
||
if err := validators.ValidateEmail(req.Email); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Normalize phone (remove spaces, hyphens, parentheses)
|
||
req.Phone = strings.Map(func(r rune) rune {
|
||
if r >= '0' && r <= '9' || r == '+' {
|
||
return r
|
||
}
|
||
return -1
|
||
}, req.Phone)
|
||
|
||
// Validate UK phone number
|
||
phone, err := ValidateUKPhoneNumber(req.Phone)
|
||
if err != nil {
|
||
http.Error(w, "invalid phone number format", http.StatusBadRequest)
|
||
return
|
||
}
|
||
req.Phone = strings.TrimSpace(phone)
|
||
|
||
// Convert names to title case
|
||
// Create per-call caser (cases.Caser is not goroutine-safe)
|
||
tc := cases.Title(language.English)
|
||
req.FirstName = tc.String(strings.ToLower(req.FirstName))
|
||
req.LastName = tc.String(strings.ToLower(req.LastName))
|
||
|
||
// Parse date of birth
|
||
dob, err := time.Parse("2006-01-02", req.DateOfBirth)
|
||
if err != nil {
|
||
http.Error(w, "invalid date format", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Reject if younger than 16
|
||
if !dob.Before(clock.Now().AddDate(-16, 0, 0)) {
|
||
http.Error(w, "account creation prohibited for users under 16. Please call to book an appointment.", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Validate referral code if provided
|
||
var referrerID *string
|
||
req.ReferralCode = strings.ToLower(strings.TrimSpace(req.ReferralCode))
|
||
if req.ReferralCode != "" {
|
||
if len(req.ReferralCode) != 12 {
|
||
http.Error(w, "referral code must be exactly 12 characters", http.StatusBadRequest)
|
||
return
|
||
}
|
||
referralCodeRegex := regexp.MustCompile(`^[a-zA-Z0-9]{12}$`)
|
||
if !referralCodeRegex.MatchString(req.ReferralCode) {
|
||
http.Error(w, "referral code must be alphanumeric", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// Look up referrer by referral code
|
||
err := db.Conn.QueryRow(r.Context(),
|
||
"SELECT id FROM users WHERE referral_code = $1", req.ReferralCode).Scan(&referrerID)
|
||
if err != nil {
|
||
http.Error(w, "invalid referral code", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Hash password
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
http.Error(w, "server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
tx, err := db.Conn.Begin(r.Context())
|
||
if err != nil {
|
||
http.Error(w, "server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
now := clock.Now()
|
||
|
||
// Insert and return the generated ID
|
||
var userID string
|
||
err = tx.QueryRow(r.Context(), `
|
||
INSERT INTO users
|
||
(n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
|
||
account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
|
||
created_at, updated_at)
|
||
VALUES
|
||
($1, $2, $3, $4, $5, $6, 'email', $7, $8, $8, $8)
|
||
RETURNING id
|
||
`, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID)
|
||
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "duplicate key") {
|
||
http.Error(w, "an account with this email already exists", http.StatusConflict)
|
||
} else {
|
||
http.Error(w, "could not create user", http.StatusInternalServerError)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Record referral relationship if referral code was provided
|
||
if referrerID != nil {
|
||
_, err = tx.Exec(r.Context(), `
|
||
INSERT INTO user_referrals (referrer_id, referred_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT DO NOTHING
|
||
`, *referrerID, userID)
|
||
if err != nil {
|
||
http.Error(w, "could not process referral", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
http.Error(w, "server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
go func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("Panic recovered in CardDAV contact creation: %v", r)
|
||
}
|
||
}()
|
||
input := dav.ContactInput{
|
||
UserID: userID,
|
||
FirstName: req.FirstName,
|
||
LastName: req.LastName,
|
||
Email: req.Email,
|
||
Phone: req.Phone,
|
||
DOB: req.DateOfBirth,
|
||
}
|
||
if err := dav.Service.CreateContact(1, userID, input); err != nil {
|
||
log.Printf("Warning: Failed to create contact in DAV for user %s: %v", userID, err)
|
||
}
|
||
}()
|
||
|
||
w.WriteHeader(http.StatusCreated)
|
||
}
|
||
|
||
func ValidateUKPhoneNumber(phone string) (string, error) {
|
||
num, err := phonenumbers.Parse(phone, "GB")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if !phonenumbers.IsValidNumber(num) {
|
||
return "", fmt.Errorf("invalid phone number")
|
||
}
|
||
|
||
// Check if it's actually a UK number
|
||
if phonenumbers.GetRegionCodeForNumber(num) != "GB" {
|
||
return "", fmt.Errorf("only UK numbers allowed")
|
||
}
|
||
|
||
// Format in E.164 format (+44...)
|
||
return phonenumbers.Format(num, phonenumbers.E164), nil
|
||
}
|
||
|
||
// POST /api/login
|
||
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req LoginRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
mw.RespondError(w, http.StatusBadRequest, "invalid request")
|
||
return
|
||
}
|
||
|
||
if err := validators.Validate.Struct(&req); err != nil {
|
||
log.Printf("Login validation failed: %v", err)
|
||
mw.RespondError(w, http.StatusBadRequest, "Email and password are required")
|
||
return
|
||
}
|
||
|
||
// Normalize email
|
||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||
|
||
var userID, passwordHash, role string
|
||
ctx := r.Context()
|
||
err := db.Conn.QueryRow(ctx, `
|
||
SELECT id, password_hash, account_role
|
||
FROM users
|
||
WHERE email = $1 AND account_type = 'email'
|
||
`, req.Email).Scan(&userID, &passwordHash, &role)
|
||
|
||
if err != nil {
|
||
// F2-HIGH (user-existence timing oracle): a non-existent email used to
|
||
// return before any bcrypt work, so its latency (~1 DB round trip) was
|
||
// measurably shorter than a wrong-password attempt against an existing
|
||
// account (~1 DB round trip + ~60ms bcrypt) — an attacker could probe
|
||
// which emails are registered from response timing. Burn the same
|
||
// constant-time bcrypt compare a real login would, under the shared
|
||
// bcrypt slot budget, and discard the result. The dummy hash is a real
|
||
// bcrypt hash (see dummyPasswordHash) so the compare runs the full cost.
|
||
if release, ok := acquireBcryptSlot(); ok {
|
||
_ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(req.Password))
|
||
release()
|
||
}
|
||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Check if account is locked (F5.5): the response MUST be indistinguishable
|
||
// from a generic invalid-credentials failure — same status, same body, and
|
||
// the same constant-time bcrypt work — so an attacker can never tell
|
||
// "locked" from "wrong password". A distinguishable lockout (the old 429
|
||
// "account is temporarily locked") is an account-existence oracle AND a
|
||
// lockout-probing signal: an attacker burning 5 wrong passwords to DoS a
|
||
// victim could then watch the victim's lockout state flip. The lockout
|
||
// itself is inherent to the 5-attempt progressive policy; hiding the state
|
||
// is what removes the oracle. The audit line stays server-side only.
|
||
// A locked-out user's recovery is the backend-only password-reset flow (see
|
||
// the success-path TODO below) or an operator clearing the columns at the DB.
|
||
var failedAttempts int
|
||
var lockedUntil *time.Time
|
||
err = db.Conn.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
|
||
if err == nil && lockedUntil != nil && clock.Now().Before(*lockedUntil) {
|
||
// Burn the same constant-time bcrypt compare a real login would, so
|
||
// the locked path's latency cannot distinguish it either. The compare
|
||
// result is discarded: a locked account stays locked (and the result
|
||
// could match if the attacker guessed the password — still no login).
|
||
if release, ok := acquireBcryptSlot(); ok {
|
||
_ = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password))
|
||
release()
|
||
}
|
||
log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context()))
|
||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Per-user in-flight slot (finding 5): the same account cannot have two
|
||
// concurrent login flows. A re-entry inside the slot window is rejected 429
|
||
// (not 409 — no conflict with a finished attempt, and a Conflict response
|
||
// would leak that a login for this account is mid-flight). Stale entries are
|
||
// evicted before the cap check so a single attacker holding N fake entries
|
||
// cannot exhaust the global budget: at most maxLoginInProgress genuinely
|
||
// concurrent logins occupy the map, each self-releasing via the deferred
|
||
// delete below.
|
||
loginStateMu.Lock()
|
||
evictStaleLoginEntriesLocked(clock.Now())
|
||
if t, ok := loginInProgress[userID]; ok && clock.Now().Sub(t) < loginInProgressWindow {
|
||
loginStateMu.Unlock()
|
||
mw.RespondError(w, http.StatusTooManyRequests, "login already in progress")
|
||
return
|
||
}
|
||
// Cap the map size - drop new request if at capacity
|
||
if len(loginInProgress) >= maxLoginInProgress {
|
||
loginStateMu.Unlock()
|
||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||
return
|
||
}
|
||
loginInProgress[userID] = clock.Now()
|
||
loginStateMu.Unlock()
|
||
|
||
// Always clear flag when done
|
||
defer func() {
|
||
loginStateMu.Lock()
|
||
delete(loginInProgress, userID)
|
||
loginStateMu.Unlock()
|
||
}()
|
||
|
||
// Verify password under the concurrency cap (finding 4b). The slot is
|
||
// released immediately after the compare — bcrypt is the expensive,
|
||
// amplifier-prone part; the DB work below is cheap. The deferred delete
|
||
// above releases this user's in-flight slot on every path.
|
||
release, ok := acquireBcryptSlot()
|
||
if !ok {
|
||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||
return
|
||
}
|
||
passwordOK := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) == nil
|
||
release()
|
||
|
||
if !passwordOK {
|
||
// Increment failed attempts in DB with progressive lockout
|
||
var newFailed int
|
||
var newLockedUntil *time.Time
|
||
tx, err := db.Conn.Begin(r.Context())
|
||
if err != nil {
|
||
log.Printf("Failed to begin transaction: %v", err)
|
||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
err = tx.QueryRow(r.Context(), `
|
||
UPDATE users
|
||
SET failed_attempts = failed_attempts + 1,
|
||
locked_until = CASE
|
||
WHEN failed_attempts + 1 >= 5 THEN NOW() + (CASE
|
||
WHEN failed_attempts + 1 >= 10 THEN INTERVAL '60 minutes'
|
||
WHEN failed_attempts + 1 >= 7 THEN INTERVAL '30 minutes'
|
||
ELSE INTERVAL '15 minutes'
|
||
END)
|
||
ELSE locked_until
|
||
END
|
||
WHERE id = $1
|
||
RETURNING failed_attempts, locked_until
|
||
`, userID).Scan(&newFailed, &newLockedUntil)
|
||
if err != nil {
|
||
log.Printf("Failed to update failed login attempts: %v", err)
|
||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
log.Printf("Failed to commit transaction: %v", err)
|
||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
log.Printf("LOGIN_AUDIT: failed login user=%s ip=%s attempts=%d locked_until=%v",
|
||
userID, middleware.GetClientIP(r.Context()), newFailed, newLockedUntil)
|
||
|
||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// On success, clear lockout and update last_login
|
||
//
|
||
// LOW 6 documented gap (finding 6): there is NO password-reset UI — the
|
||
// backend-only reset flow (GenerateVerificationCodeHandler/
|
||
// VerifyCodeHandler) has no frontend link, so a user locked out by a
|
||
// guessing attacker has no self-service recovery until locked_until lapses
|
||
// (15min at 5+ failures, 30min at 7+, 60min at 10+ — see the failure path
|
||
// above); the operator can only intervene at the DB. The escalating ceiling
|
||
// is the repeat-DoS mitigation: an attacker who keeps guessing past each
|
||
// unlock makes the lock LONGER (up to 60 minutes) instead of merely
|
||
// sustaining the 15-minute tier, raising the effort-per-DoS ratio while the
|
||
// response stays the uniform 401 (never distinguishable from a wrong
|
||
// password). The lockout counter stays keyed per-user (not per-(user,IP))
|
||
// because this codebase deliberately rejects IP-in-the-key for account-level
|
||
// budgets (see the 2FA limiter note in main.go, B8): a client that rotates
|
||
// its source IP would mint a fresh bucket per IP and collapse the per-account
|
||
// budget. The 60-minute ceiling is the bounded-DoS compromise; a successful
|
||
// 2FA verify also clears the lockout (internal/twofa.Check), and — since
|
||
// the HIGH finding wiring — so does a successful password_reset verification
|
||
// code (VerifyCodeHandler), giving a locked-out user a self-service recovery
|
||
// path (generate → verify → log in → change password).
|
||
tx, err := db.Conn.Begin(r.Context())
|
||
if err != nil {
|
||
log.Printf("Failed to begin transaction: %v", err)
|
||
mw.RespondError(w, http.StatusInternalServerError, "internal server error")
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
_, err = tx.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
|
||
if err != nil {
|
||
log.Printf("Failed to reset login attempts on success: %v", err)
|
||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
log.Printf("Failed to commit transaction: %v", err)
|
||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Issue an opaque refresh token (B5): the 90-day credential is stored
|
||
// hashed in refresh_tokens and rotated on every use. A stolen ACCESS token
|
||
// can no longer self-renew — only a valid, unexpired, unrevoked refresh
|
||
// token can mint a new pair. The refresh token is returned in the body so
|
||
// the SPA can persist it and present it to POST /api/refresh-token. The
|
||
// access token is bound to the new rotation family (HIGH 1) so that if the
|
||
// login refresh token is ever replayed the whole family — access token
|
||
// included — is killed.
|
||
refreshToken, familyID, err := auth.GenerateRefreshToken(r.Context(), userID, role)
|
||
if err != nil {
|
||
log.Printf("failed to issue refresh token for user %s: %v", userID, err)
|
||
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Generate the access token AFTER the refresh token so it can be bound to
|
||
// the same rotation family.
|
||
tokenString, jti, err := auth.GenerateTokenForFamily(userID, role, familyID)
|
||
if err != nil {
|
||
log.Printf("failed to generate access token for user %s: %v", userID, err)
|
||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the login response contract
|
||
Token: tokenString,
|
||
JTI: jti,
|
||
RefreshToken: refreshToken,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
// POST /api/refresh-token
|
||
// Requires a valid refresh token in the Authorization header (Bearer). The
|
||
// opaque refresh token is validated against the DB (hashed), consumed
|
||
// (rotated), and exchanged for a fresh access token + a NEW refresh token.
|
||
//
|
||
// B5 (security): the handler deliberately does NOT accept the access token.
|
||
// VerifyRefreshToken rotates (marks used) the presented refresh token, so a
|
||
// stolen access token can never self-renew — it expires in 1 hour and only a
|
||
// valid, unexpired, unrevoked refresh token can mint a new pair. A replayed
|
||
// refresh token (used twice) returns 401, detecting theft via rotation and
|
||
// revoking the entire rotation family with a critical admin alert.
|
||
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||
authHeader := r.Header.Get("Authorization")
|
||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||
mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization header"})
|
||
return
|
||
}
|
||
refreshToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||
|
||
// VerifyRefreshToken consumes (rotates) the refresh token: the used token
|
||
// is marked used in refresh_tokens, so a stolen/leaked refresh token cannot
|
||
// be replayed and an access token alone can never mint a new session. A
|
||
// replayed (already-rotated) token revokes the entire rotation family and
|
||
// raises a critical admin alert, but still surfaces as this generic 401.
|
||
userID, role, familyID, err := auth.VerifyRefreshToken(r.Context(), refreshToken)
|
||
if err != nil {
|
||
mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
|
||
return
|
||
}
|
||
|
||
// Verify user still exists and role hasn't changed
|
||
var currentRole string
|
||
err = db.Conn.QueryRow(r.Context(), `
|
||
SELECT account_role FROM users WHERE id = $1
|
||
`, userID).Scan(¤tRole)
|
||
|
||
if err != nil {
|
||
http.Error(w, "user not found", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
if currentRole != role {
|
||
http.Error(w, "role changed, please log in again", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Issue a fresh access token + refresh token pair. The rotated refresh
|
||
// token is minted in the SAME family (familyID from VerifyRefreshToken) so
|
||
// a replayed ancestor can revoke the whole lineage, descendants included.
|
||
// The access token is bound to that same family (HIGH 1): when reuse
|
||
// detection kills the family, the freshly-minted access token handed to the
|
||
// attacker at rotation dies with it instead of staying valid for 1 hour.
|
||
newRefreshToken, err := auth.GenerateRefreshTokenInFamily(r.Context(), userID, currentRole, familyID)
|
||
if err != nil {
|
||
log.Printf("failed to issue rotated refresh token for user %s: %v", userID, err)
|
||
mw.RespondError(w, http.StatusInternalServerError, "could not generate refresh token")
|
||
return
|
||
}
|
||
|
||
// Mint the access token AFTER the descendant refresh token so it can be
|
||
// bound to the same rotation family.
|
||
newToken, jti, err := auth.GenerateTokenForFamily(userID, currentRole, familyID)
|
||
if err != nil {
|
||
mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
|
||
return
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the refresh-token response contract
|
||
Token: newToken,
|
||
JTI: jti,
|
||
RefreshToken: newRefreshToken,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
// POST /api/logout (requires auth middleware)
|
||
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||
jti, ok := mw.GetJTI(r.Context())
|
||
if !ok || jti == "" {
|
||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
userID, _ := mw.GetUserID(r.Context())
|
||
|
||
// Revoke the JTI — match the access token lifetime (1 hour)
|
||
if err := auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour)); err != nil {
|
||
slog.Error("logout: failed to revoke JTI", "err", err)
|
||
mw.RespondError(w, http.StatusInternalServerError, "failed to revoke token. please try again.")
|
||
return
|
||
}
|
||
|
||
// B5: logging out must also kill the outstanding refresh credential for
|
||
// THIS session, or a previously-issued (possibly stolen) refresh token
|
||
// would keep the session alive past logout.
|
||
//
|
||
// LOW-1: the revocation is scoped to the PRESENTED access token's rotation
|
||
// family (family_id claim) instead of every refresh token the user holds.
|
||
// A stolen access token can no longer wipe every session the user keeps on
|
||
// other devices — only this token's own lineage dies, which is all B5
|
||
// needs (the presented refresh token lives in that family). An unbound
|
||
// token (no family_id claim — test/legacy minting via GenerateToken)
|
||
// falls back to the user-wide delete, preserving the old behaviour for
|
||
// those tokens.
|
||
familyID := auth.FamilyIDFromToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||
if familyID != "" {
|
||
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE family_id = $1`, familyID); err != nil {
|
||
slog.Error("logout: failed to revoke refresh token family", "familyID", familyID, "err", err)
|
||
}
|
||
// Drop the family-alive cache verdict so this family's access tokens
|
||
// (the logged-out one and any in-flight duplicates) are re-checked
|
||
// against the now-empty refresh_tokens on their next request.
|
||
auth.InvalidateFamilyAlive(familyID)
|
||
} else if userID != "" {
|
||
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE user_id = $1`, userID); err != nil {
|
||
slog.Error("logout: failed to revoke refresh tokens", "userID", userID, "err", err)
|
||
}
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
type VerificationCodeRequest struct {
|
||
Email string `json:"email" validate:"required,email,max=254"`
|
||
// Purpose is the verification_purpose the code authorises: "email_verify"
|
||
// (default, escalates unverified_email → verified_email) or "password_reset"
|
||
// (clears a login lockout — see VerifyCodeHandler). Validated in code so the
|
||
// comparison is case-insensitive after trim/lower.
|
||
Purpose string `json:"purpose,omitempty"`
|
||
}
|
||
|
||
type VerifyCodeRequest struct {
|
||
Code string `json:"code" validate:"required,max=64"`
|
||
}
|
||
|
||
type VerificationResponse struct {
|
||
Success bool `json:"success"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
// generateVerificationCode returns a 12-hex-character code (48 bits of
|
||
// randomness), matching the old DB-default generator
|
||
// (gen_random_bytes(6) hex). Only its HMAC-SHA256 digest is ever persisted; the
|
||
// plaintext exists solely to be delivered out-of-band (dev [VERIFY] log relay,
|
||
// or the future SMTP channel) and is never stored.
|
||
func generateVerificationCode() (string, error) {
|
||
buf := make([]byte, 6)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// POST /api/verify/generate
|
||
// Creates a verification_codes row for the account matching the submitted
|
||
// email (if any), storing ONLY the HMAC-SHA256 digest of a fresh
|
||
// 12-hex-char code (pepper-keyed via crussell/internal/twofa — see
|
||
// verificationCodePepperEnv). The plaintext code is delivered build-dependently
|
||
// (verifycode_dev.go / verifycode_prod.go): dev/test builds write it to the
|
||
// server log ([VERIFY] prefix) — the loose-fake stand-in for the not-yet-wired
|
||
// email/SMS transport (P6) — while production builds fail closed when
|
||
// TWO_FACTOR_PEPPER is unset (an unsalted digest in the 48-bit code space
|
||
// would be offline-brute-forceable from a DB leak) or when no delivery channel
|
||
// is configured (email/SMS unwired; stdout-log delivery is a dev/test-only
|
||
// local feature). The response is IDENTICAL whether or not the email exists, so
|
||
// the endpoint cannot be used to enumerate registered addresses.
|
||
//
|
||
// The purpose field wires the lockout-recovery flow (HIGH finding): a locked-out
|
||
// user requests a password_reset code for their own email, obtains it (dev log /
|
||
// operator relay), verifies it at /api/verify/check, and the lockout is cleared
|
||
// so they can log in and change their password.
|
||
func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req VerificationCodeRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := validators.Validate.Struct(&req); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||
if email == "" {
|
||
http.Error(w, "email is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
purpose := strings.TrimSpace(strings.ToLower(req.Purpose))
|
||
if purpose == "" {
|
||
purpose = verificationCodePurposeEmailVerify
|
||
}
|
||
if purpose != verificationCodePurposeEmailVerify && purpose != verificationCodePurposePasswordReset {
|
||
http.Error(w, "purpose must be 'email_verify' or 'password_reset'", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// A locked-out user reaches this endpoint UNAUTHENTICATED by design (the
|
||
// whole point of password_reset recovery), so no auth middleware guards it;
|
||
// the per-IP rate limit on the route is the only throttle, matching the
|
||
// 2FA mint paths.
|
||
|
||
// Build-dependent issuance gate (pepper + delivery channel in production;
|
||
// always allowed in dev/test — see verifycode_dev.go / verifycode_prod.go).
|
||
if err := verificationCodeEnsureIssueAllowed(); err != nil {
|
||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
|
||
// Fail-closed reference: the user lookup happens AFTER the issuance gate so
|
||
// a prod deployment without the pepper/delivery channel refuses BEFORE any
|
||
// per-email work (and before the enumeration-uniform path below is reached).
|
||
var userID string
|
||
err := db.Conn.QueryRow(r.Context(),
|
||
"SELECT id FROM users WHERE LOWER(email) = $1", email,
|
||
).Scan(&userID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
// Uniform anti-enumeration response — byte-identical to the
|
||
// existing-user branch.
|
||
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
return
|
||
}
|
||
log.Printf("Failed to look up user: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
code, err := generateVerificationCode()
|
||
if err != nil {
|
||
log.Printf("Failed to generate verification code: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||
|
||
// Persist ONLY the digest; the plaintext code exists only in the delivery
|
||
// channel (log relay / future SMTP).
|
||
_, err = db.Conn.Exec(r.Context(),
|
||
`INSERT INTO verification_codes (user_id, purpose, code, expires_at) VALUES ($1, $2, $3, $4)`,
|
||
userID, purpose, twofa.Hash(code), expiresAt,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Failed to insert verification code: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
verificationCodeDeliver(userID, purpose, code)
|
||
|
||
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
// POST /api/verify/check
|
||
// Consumes a verification code submitted by an UNAUTHENTICATED caller (there is
|
||
// no auth middleware on this route — the password_reset recovery flow must be
|
||
// reachable by a locked-out user). The submitted code is hashed the same way it
|
||
// was stored (twofa.Hash) and matched against verification_codes; a match
|
||
// resolves the owning user, and the brute-force attempt budget is keyed PER
|
||
// USER from that point on (see the emailVerifyAttempts* docs). On a valid,
|
||
// unexpired, unused code:
|
||
//
|
||
// - purpose email_verify: escalates the account to verified_email;
|
||
// - purpose password_reset: clears failed_attempts / locked_until so the
|
||
// account owner can log in and change their password (the lockout-recovery
|
||
// path for the login-DoS finding).
|
||
func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req VerifyCodeRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := validators.Validate.Struct(&req); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
code := strings.TrimSpace(req.Code)
|
||
if code == "" {
|
||
http.Error(w, "code is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Finding 8: a spent attempt budget rejects before any DB work — the code
|
||
// can no longer be guessed against. This pre-check uses the submitted code
|
||
// as the key (a guess's miss path; the per-user key cannot be derived until
|
||
// a row resolves it).
|
||
if emailVerifyAttemptsExhausted(code) {
|
||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
|
||
codeDigest := twofa.Hash(code)
|
||
|
||
var userID string
|
||
var purpose string
|
||
var expiresAt time.Time
|
||
var usedAt *time.Time
|
||
|
||
err := db.Conn.QueryRow(r.Context(),
|
||
`SELECT user_id, purpose, expires_at, used_at FROM verification_codes
|
||
WHERE code = $1`,
|
||
codeDigest,
|
||
).Scan(&userID, &purpose, &expiresAt, &usedAt)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
// No row with this digest at all — a guess. No user can be
|
||
// resolved, so the attempt budget stays keyed per submitted code.
|
||
if emailVerifyAttemptFailed(code) {
|
||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
http.Error(w, "invalid or expired code", http.StatusBadRequest)
|
||
return
|
||
}
|
||
log.Printf("Failed to verify code: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// The code resolved to exactly one user — from here the attempt budget is
|
||
// keyed PER USER, so an attacker draining a victim's codes cannot get a
|
||
// fresh budget per submitted value.
|
||
if emailVerifyAttemptsExhausted(userID) {
|
||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
|
||
// Code exists but was already used — a definite state, not a guess.
|
||
if usedAt != nil {
|
||
http.Error(w, "code already used", http.StatusForbidden)
|
||
return
|
||
}
|
||
// Code exists but expired — count it against the user's budget too.
|
||
if !expiresAt.After(clock.Now()) {
|
||
if emailVerifyAttemptFailed(userID) {
|
||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
http.Error(w, "invalid or expired code", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := db.Conn.Begin(r.Context())
|
||
if err != nil {
|
||
log.Printf("Failed to start transaction: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
_, err = tx.Exec(r.Context(),
|
||
`UPDATE verification_codes SET used_at = NOW() WHERE code = $1`,
|
||
codeDigest,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Failed to mark code as used: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
message := "Email verified successfully"
|
||
switch purpose {
|
||
case verificationCodePurposeEmailVerify:
|
||
_, err = tx.Exec(r.Context(),
|
||
`UPDATE users SET account_role = 'verified_email' WHERE id = $1 AND account_role = 'unverified_email'`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Failed to update user role: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
case verificationCodePurposePasswordReset:
|
||
// Lockout-recovery consumer (HIGH finding): a verified password_reset
|
||
// code proves control of the account's email, so the login lockout is
|
||
// lifted. The user then logs in and changes their password via the
|
||
// existing change-password flow.
|
||
message = "Verification successful - login lockout cleared"
|
||
_, err = tx.Exec(r.Context(),
|
||
`UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Failed to clear login lockout for password_reset: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
log.Printf("Failed to commit verification: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// A successful verify clears the user's attempt budget (and the miss-path
|
||
// key the submitted code used on earlier guesses).
|
||
emailVerifyAttemptsClear(userID)
|
||
emailVerifyAttemptsClear(code)
|
||
|
||
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: message}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|