// 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" "os" "strings" "sync" "time" "github.com/cclohmar/ReceiptNext/internal/auth" "github.com/cclohmar/ReceiptNext/internal/database" "github.com/cclohmar/ReceiptNext/internal/email" "github.com/cclohmar/ReceiptNext/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 otpMu sync.Mutex // prevents OTP reuse via race conditions } // --------------------------------------------------------------------------- // 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 := 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. // // 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.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 — // during development the code is visible in server logs. 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. // // 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 == "" { 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 (still under lock). 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), }) // 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, `
%s
`, 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. // If errMsg is non-empty, it is displayed as an error banner above the form. func renderOTPForm(w http.ResponseWriter, email string, errMsg string) { tmpl := template.Must(template.New("otp_form").Parse(`
{{if .Error}}
{{.Error}}
{{end}}
`)) 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 individual digit form values and concatenates them // into a single 6-character OTP code string. Returns an empty string if any // digit is missing. // Logout clears the session cookie and invalidates the server-side session. func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { // Invalidate the server-side session. if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" { h.Sessions.Delete(cookie.Value) } // Clear the cookie on the client side. 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) } 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() }