// Package handlers implements HTTP handlers for NextReceipt, providing // passwordless email OTP authentication and purchase management. package handlers import ( "database/sql" "fmt" "html/template" "log" "net/http" "os" "strings" "sync" "time" "github.com/cclohmar/NextReceipt/internal/auth" "github.com/cclohmar/NextReceipt/internal/database" "github.com/cclohmar/NextReceipt/internal/email" "github.com/cclohmar/NextReceipt/internal/utils" ) // --------------------------------------------------------------------------- // AuthHandler // --------------------------------------------------------------------------- // AuthHandler handles passwordless email OTP authentication endpoints. type AuthHandler struct { DB *sql.DB Sessions *auth.SessionStore FailureTracker *auth.FailureTracker EmailSender *email.Sender otpMu sync.Mutex // prevents OTP reuse via race conditions } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- // LandingPage renders the landing page with the email input form for OTP login. func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) { // If the user already has a valid session, redirect to the dashboard. if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" { if _, ok := h.Sessions.Get(cookie.Value); ok { w.Header().Set("HX-Redirect", "/dashboard") w.WriteHeader(http.StatusOK) return } } tmpl := getTemplate("index.html") w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, nil); err != nil { log.Printf("ERROR [%s] handlers: LandingPage execute template: %v", time.Now().Format(time.RFC3339), err) } } // RequestOTP handles OTP generation and email delivery. func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) { emailAddr := strings.TrimSpace(r.FormValue("email")) if emailAddr == "" { renderError(w, "Email is required.") return } // Rate-limit check: 3 failed attempts trigger a 1-minute lockout. if h.FailureTracker.IsLockedOut(emailAddr) { renderError(w, "Too many attempts. Please wait 1 minute before trying again.") return } // Retrieve or create the user. user, err := database.GetUserByEmail(h.DB, emailAddr) if err != nil { log.Printf("ERROR [%s] handlers: RequestOTP GetUserByEmail(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) renderError(w, "An error occurred. Please try again.") return } if user == nil { userID := utils.NewUUID() if err := database.CreateUser(h.DB, userID, emailAddr); err != nil { log.Printf("ERROR [%s] handlers: RequestOTP CreateUser(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) renderError(w, "An error occurred. Please try again.") return } user = &database.User{ID: userID, Email: emailAddr} } // Generate a cryptographically secure 6-digit OTP. code, err := auth.GenerateOTP() if err != nil { log.Printf("ERROR [%s] handlers: RequestOTP GenerateOTP: %v", time.Now().Format(time.RFC3339), err) renderError(w, "An error occurred. Please try again.") return } // Persist the OTP with a 5-minute expiry. expiresAt := time.Now().Add(5 * time.Minute) if err := database.SaveOTP(h.DB, emailAddr, code, expiresAt.Format(time.RFC3339)); err != nil { log.Printf("ERROR [%s] handlers: RequestOTP SaveOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) renderError(w, "An error occurred. Please try again.") return } // Deliver OTP via email. Log the error but do not fail the request. if h.EmailSender != nil { if err := h.EmailSender.SendOTP(emailAddr, code); err != nil { log.Printf("ERROR [%s] handlers: RequestOTP SendOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) } } else { log.Printf("WARN [%s] handlers: RequestOTP(%s): SMTP not configured — OTP code %s not delivered via email", time.Now().Format(time.RFC3339), emailAddr, code) } // Render the OTP verification form as an HTMX fragment. renderOTPForm(w, emailAddr, "") } // VerifyOTP handles OTP code verification and session creation. func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) { emailAddr := strings.TrimSpace(r.FormValue("email")) otpCode := collectOTP(r) if emailAddr == "" || otpCode == "" { renderOTPForm(w, emailAddr, "Email and OTP code are required.") return } // Fetch the stored OTP record. stored, err := database.GetOTP(h.DB, emailAddr) if err != nil { log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) renderOTPForm(w, emailAddr, "An error occurred. Please try again.") return } if stored == nil { renderOTPForm(w, emailAddr, "No OTP found for this email. Please request a new code.") return } // Parse the stored expiry timestamp. expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt) if err != nil { log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err) renderOTPForm(w, emailAddr, "An error occurred. Please try again.") return } // Validate + delete OTP atomically to prevent race-condition reuse. h.otpMu.Lock() if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) { h.otpMu.Unlock() h.FailureTracker.RecordFailure(emailAddr) renderOTPForm(w, emailAddr, "Invalid or expired OTP code. Please try again.") return } // Successful verification: delete OTP immediately. h.FailureTracker.Reset(emailAddr) if err := database.DeleteOTP(h.DB, emailAddr); err != nil { log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) } h.otpMu.Unlock() // Retrieve the user record to obtain the user ID. user, err := database.GetUserByEmail(h.DB, emailAddr) if err != nil || user == nil { log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err) renderOTPForm(w, emailAddr, "An error occurred. Please try again.") return } // Generate an in-memory session token. token, err := h.Sessions.Generate(user.ID) if err != nil { log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err) renderOTPForm(w, emailAddr, "An error occurred. Please try again.") return } // Set the session cookie (HttpOnly, SameSite=Lax, Secure, 24h). secure := strings.HasPrefix(os.Getenv("BASE_URL"), "https://") http.SetCookie(w, &http.Cookie{ Name: "session_token", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure, Expires: time.Now().Add(24 * time.Hour), }) // Always redirect to dashboard (no onboarding step needed). w.Header().Set("HX-Redirect", "/dashboard") w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- // RequireAuth is HTTP middleware that validates the session cookie on protected // routes. If the session is invalid or expired it redirects to the landing page. func (h *AuthHandler) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_token") if err != nil { w.Header().Set("HX-Redirect", "/") w.WriteHeader(http.StatusUnauthorized) return } userID, ok := h.Sessions.Get(cookie.Value) if !ok { w.Header().Set("HX-Redirect", "/") w.WriteHeader(http.StatusUnauthorized) return } r.Header.Set("X-User-ID", userID) next.ServeHTTP(w, r) }) } // getUserID returns the authenticated user ID from the request. func getUserID(r *http.Request) string { return r.Header.Get("X-User-ID") } // --------------------------------------------------------------------------- // Logout // --------------------------------------------------------------------------- // Logout clears the session cookie and invalidates the server-side session. func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" { h.Sessions.Delete(cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: "session_token", Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) w.Header().Set("HX-Redirect", "/") w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- // renderError writes an HTMX-compatible HTML error fragment to the response. func renderError(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
`, template.HTMLEscapeString(message)) } // renderOTPForm writes the OTP verification form partial as an HTMX fragment. func renderOTPForm(w http.ResponseWriter, email string, errMsg string) { tmpl := getTemplate("otp_form") w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, map[string]string{"Email": email, "Error": errMsg}); err != nil { log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err) } } // collectOTP reads the 6-digit OTP code from the form value. func collectOTP(r *http.Request) string { code := r.FormValue("otp_code") code = strings.Map(func(r rune) rune { if r >= '0' && r <= '9' { return r } return -1 }, code) if len(code) != 6 { return "" } return code }