Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
695 lines
20 KiB
Go
695 lines
20 KiB
Go
package auth
|
|
|
|
import (
|
|
"crussell/auth"
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"github.com/jackc/pgx/v5"
|
|
"crussell/internal/dav"
|
|
"crussell/internal/validators"
|
|
"crussell/internal/zxcvbnjs"
|
|
"crussell/mw"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nyaruka/phonenumbers"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
|
|
|
|
const maxLoginInProgress = 20
|
|
|
|
// Login state management
|
|
var (
|
|
loginStateMu sync.Mutex
|
|
loginInProgress = make(map[string]time.Time)
|
|
)
|
|
|
|
func init() {
|
|
go func() {
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in login state cleanup ticker: %v", r)
|
|
}
|
|
}()
|
|
loginStateMu.Lock()
|
|
now := clock.Now()
|
|
// Clean up stuck loginInProgress entries (older than 30s)
|
|
for userID, startedAt := range loginInProgress {
|
|
if now.Sub(startedAt) > 30*time.Second {
|
|
delete(loginInProgress, userID)
|
|
}
|
|
}
|
|
loginStateMu.Unlock()
|
|
}()
|
|
}
|
|
}()
|
|
}
|
|
|
|
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 {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Must accept terms
|
|
if !req.AgreedToPolicy {
|
|
http.Error(w, "must agree to terms", http.StatusBadRequest)
|
|
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 {
|
|
http.Error(w, "password must be 72 characters or less", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.Password) < 6 {
|
|
http.Error(w, "password must be at least 6 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// 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 {
|
|
http.Error(w, err.Error(), 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 tx.Rollback(r.Context())
|
|
|
|
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 {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := validators.Validate.Struct(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
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 {
|
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Check if account is locked
|
|
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) {
|
|
http.Error(w, "account is temporarily locked. try again later.", http.StatusTooManyRequests)
|
|
log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context()))
|
|
return
|
|
}
|
|
|
|
// Check if user is already logging in
|
|
loginStateMu.Lock()
|
|
if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second {
|
|
loginStateMu.Unlock()
|
|
http.Error(w, "login already in progress", http.StatusConflict) // 409
|
|
return
|
|
}
|
|
// Cap the map size - drop new request if at capacity
|
|
if len(loginInProgress) >= maxLoginInProgress {
|
|
loginStateMu.Unlock()
|
|
http.Error(w, "server busy, try again later", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
loginInProgress[userID] = clock.Now()
|
|
loginStateMu.Unlock()
|
|
|
|
// Always clear flag when done
|
|
defer func() {
|
|
loginStateMu.Lock()
|
|
delete(loginInProgress, userID)
|
|
loginStateMu.Unlock()
|
|
}()
|
|
|
|
// Verify password
|
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
|
// 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 tx.Rollback(r.Context())
|
|
|
|
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 >= 20 THEN INTERVAL '2 hours'
|
|
WHEN failed_attempts + 1 >= 10 THEN INTERVAL '1 hour'
|
|
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
|
|
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
|
|
// failed_attempts and locked_until — a locked-out user can't call this handler.
|
|
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 tx.Rollback(r.Context())
|
|
|
|
_, 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
|
|
}
|
|
|
|
// Generate JWT
|
|
tokenString, jti, err := auth.GenerateToken(userID, role)
|
|
if err != nil {
|
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Generate refresh token for token rotation
|
|
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, role)
|
|
if err != nil {
|
|
log.Printf("Failed to generate refresh token: %v", err)
|
|
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(auth.AuthResponse{
|
|
Token: tokenString,
|
|
JTI: jti,
|
|
RefreshToken: refreshToken,
|
|
})
|
|
}
|
|
|
|
// POST /api/refresh-token (requires auth middleware)
|
|
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := mw.GetUserID(r.Context())
|
|
role, _ := mw.GetUserRole(r.Context())
|
|
oldJTI, _ := mw.GetJTI(r.Context())
|
|
|
|
// 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
|
|
}
|
|
|
|
// Revoke the old token's JTI before issuing a new one (rotation)
|
|
if oldJTI != "" {
|
|
auth.RevokeJTI(r.Context(), oldJTI, clock.Now().Add(90*24*time.Hour)) // match refresh token lifetime
|
|
}
|
|
|
|
// Generate new token
|
|
newToken, jti, err := auth.GenerateToken(userID, currentRole)
|
|
if err != nil {
|
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Also issue a refresh token (opaque, stored in DB)
|
|
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, currentRole)
|
|
if err != nil {
|
|
log.Printf("Failed to generate refresh token: %v", err)
|
|
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(auth.AuthResponse{
|
|
Token: newToken,
|
|
JTI: jti,
|
|
RefreshToken: refreshToken,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Revoke the JTI — match the access token lifetime (1 hour)
|
|
auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour))
|
|
|
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
|
}
|
|
|
|
type VerificationCodeRequest struct {
|
|
Email string `json:"email" validate:"required,email,max=254"`
|
|
}
|
|
|
|
type VerifyCodeRequest struct {
|
|
Code string `json:"code" validate:"required,max=64"`
|
|
}
|
|
|
|
type VerificationResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
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 {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
|
if email == "" {
|
|
http.Error(w, "email is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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) {
|
|
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"})
|
|
return
|
|
}
|
|
log.Printf("Failed to look up user: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
expiresAt := clock.Now().Add(24 * time.Hour)
|
|
|
|
var code string
|
|
err = db.Conn.QueryRow(r.Context(),
|
|
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
|
userID, expiresAt,
|
|
).Scan(&code)
|
|
if err != nil {
|
|
log.Printf("Failed to insert verification code: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"})
|
|
}
|
|
|
|
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 {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
code := strings.TrimSpace(req.Code)
|
|
if code == "" {
|
|
http.Error(w, "code is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var userID string
|
|
var purpose string
|
|
var expiresAt time.Time
|
|
|
|
err := db.Conn.QueryRow(r.Context(),
|
|
`SELECT user_id, purpose, expires_at FROM verification_codes
|
|
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`,
|
|
code,
|
|
).Scan(&userID, &purpose, &expiresAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Check if code exists but was already used or expired
|
|
var checkUsedAt *time.Time
|
|
checkErr := db.Conn.QueryRow(r.Context(),
|
|
`SELECT used_at FROM verification_codes WHERE code = $1`, code,
|
|
).Scan(&checkUsedAt)
|
|
if checkErr != nil {
|
|
// Code doesn't exist at all
|
|
http.Error(w, "invalid or expired code", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Code exists but was already used
|
|
if checkUsedAt != nil {
|
|
http.Error(w, "code already used", http.StatusForbidden)
|
|
return
|
|
}
|
|
// Code exists but expired
|
|
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
|
|
}
|
|
|
|
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 tx.Rollback(r.Context())
|
|
|
|
_, err = tx.Exec(r.Context(),
|
|
`UPDATE verification_codes SET used_at = NOW() WHERE code = $1`,
|
|
code,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to mark code as used: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if purpose == "email_verify" {
|
|
_, 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
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit verification: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"})
|
|
}
|
|
|
|
// TODO(M8): Replace crypto/rand fallback with proper error handling - time-based fallback is predictable
|
|
func generateSecureCode(length int) string {
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
log.Printf("Failed to generate random code: %v", err)
|
|
return strings.ToLower(fmt.Sprintf("%x", clock.Now().UnixNano()))
|
|
}
|
|
return strings.ToLower(fmt.Sprintf("%x", bytes))
|
|
}
|