// Package handlers implements HTTP handlers for ExpenseFlow, providing // passwordless email OTP authentication, event management, expense tracking, // and report generation endpoints. package handlers import ( "database/sql" "fmt" "html/template" "log" "net/http" "strings" "time" "github.com/expenseflow/internal/auth" "github.com/expenseflow/internal/database" "github.com/expenseflow/internal/email" "github.com/expenseflow/internal/utils" ) // --------------------------------------------------------------------------- // AuthHandler // --------------------------------------------------------------------------- // AuthHandler handles passwordless email OTP authentication endpoints: // - GET / — landing page with email input form // - POST /request-otp — generates and emails a 6-digit OTP code // - POST /verify-otp — validates the OTP and creates a session // // It depends on a *sql.DB for user/OTP persistence, a SessionStore for // in-memory session management, a FailureTracker for rate-limiting, and // an email.Sender for delivering OTP codes. type AuthHandler struct { DB *sql.DB Sessions *auth.SessionStore FailureTracker *auth.FailureTracker EmailSender *email.Sender } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- // LandingPage renders the landing page with the email input form for OTP login. // It parses templates/index.html and executes it with no template data. func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) { tmpl, err := template.ParseFiles("templates/index.html") if err != nil { log.Printf("ERROR [%s] handlers: LandingPage parse template: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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. // // 1. Reads the email from the form value. // 2. Checks the FailureTracker for rate-limit lockout (3 failures = 1 min cooldown). // 3. Looks up or creates a user row in the database. // 4. Generates a 6-digit OTP with a 5-minute expiry. // 5. Persists the OTP to the auth_otps table. // 6. Sends the OTP via email (logs error but does not fail the request). // 7. Returns an HTMX fragment containing the OTP verification form. 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.New() 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 — // during development the code is visible in server logs. 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) } // Render the OTP verification form as an HTMX fragment. renderOTPForm(w, emailAddr) } // VerifyOTP handles OTP code verification and session creation. // // 1. Reads email and the 6 individual digit inputs from the form. // 2. Retrieves the stored OTP record for the email. // 3. Validates the code and its expiry time. // 4. On failure: records the attempt in the FailureTracker, returns an error. // 5. On success: resets the failure count, deletes the used OTP, generates a // session token, sets an HTTP-only cookie, and redirects to /dashboard. func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) { emailAddr := strings.TrimSpace(r.FormValue("email")) otpCode := collectOTP(r) if emailAddr == "" || otpCode == "" { renderError(w, "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) renderError(w, "An error occurred. Please try again.") return } if stored == nil { renderError(w, "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) renderError(w, "An error occurred. Please try again.") return } // Validate the OTP code and expiry. if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) { h.FailureTracker.RecordFailure(emailAddr) renderError(w, "Invalid or expired OTP code. Please try again.") return } // Successful verification: clean up and create session. 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) // Non-fatal — the OTP is already validated. } // 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) renderError(w, "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) renderError(w, "An error occurred. Please try again.") return } // Set the HTTP-only session cookie with a 24-hour TTL. http.SetCookie(w, &http.Cookie{ Name: "session_token", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Expires: time.Now().Add(24 * time.Hour), }) // Redirect to the dashboard via HTMX. 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 // using the HX-Redirect header. Otherwise it sets the X-User-ID header on the // request for downstream handler use and calls the next handler. 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. // The value is set by the RequireAuth middleware on the X-User-ID header. func getUserID(r *http.Request) string { return r.Header.Get("X-User-ID") } // --------------------------------------------------------------------------- // 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. // It renders 6 individual digit input boxes for a better mobile UX, plus a // hidden email field. The handler combines the 6 digits server-side. func renderOTPForm(w http.ResponseWriter, email string) { tmpl := template.Must(template.New("otp_form").Parse(` `)) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, map[string]string{"Email": email}); err != nil { log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err) } } // collectOTP reads the 6 individual digit form values and concatenates them // into a single 6-character OTP code string. Returns an empty string if any // digit is missing. func collectOTP(r *http.Request) string { var b strings.Builder for i := 0; i < 6; i++ { digit := r.FormValue(fmt.Sprintf("digit_%d", i)) if digit == "" { return "" } b.WriteString(digit) } return b.String() }