- Rename Go module from github.com/cclohmar/ReceiptNext to NextExpense - Update all import paths across 7 Go source files - Update templates (titles, headings, branding) - Update static files (manifest.json, sw.js, CSS) - Update config (Makefile, install.sh, .env.example) - Update README with new name and URLs - Rename service file receiptnext.service -> nextexpense.service - Update install paths, service names, log paths in install.sh
452 lines
16 KiB
Go
452 lines
16 KiB
Go
// Package handlers implements HTTP handlers for NextExpense, 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/NextExpense/internal/auth"
|
|
"github.com/cclohmar/NextExpense/internal/database"
|
|
"github.com/cclohmar/NextExpense/internal/email"
|
|
"github.com/cclohmar/NextExpense/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) {
|
|
// 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.
|
|
//
|
|
// 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 appropriate page — onboarding if first login, dashboard otherwise.
|
|
if user.Onboarded {
|
|
w.Header().Set("HX-Redirect", "/dashboard")
|
|
} else {
|
|
w.Header().Set("HX-Redirect", "/onboarding")
|
|
}
|
|
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, `<div class="error-message" style="color: #fca5a5; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message))
|
|
}
|
|
|
|
// renderOTPForm writes the OTP verification form partial as an HTMX fragment
|
|
// using the cached otp_form template from templates.go.
|
|
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 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 {
|
|
code := r.FormValue("otp_code")
|
|
// Strip any non-digit characters (paste may include spaces/dashes).
|
|
code = strings.Map(func(r rune) rune {
|
|
if r >= '0' && r <= '9' {
|
|
return r
|
|
}
|
|
return -1
|
|
}, code)
|
|
if len(code) != 6 {
|
|
return ""
|
|
}
|
|
return code
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Onboarding handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// OnboardingPage renders the onboarding form that captures the user's name
|
|
// and department for report personalisation. Only shown on first login.
|
|
func (h *AuthHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
w.Header().Set("HX-Redirect", "/")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// If already onboarded, redirect to dashboard.
|
|
user, _ := database.GetUserByID(h.DB, userID)
|
|
if user != nil && user.Onboarded {
|
|
w.Header().Set("HX-Redirect", "/dashboard")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
tmpl := getTemplate("onboarding.html")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, nil); err != nil {
|
|
log.Printf("ERROR [%s] handlers: OnboardingPage: execute template: %v", time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|
|
|
|
// SaveOnboarding saves the user's name and department and marks onboarding
|
|
// as complete, then redirects to the dashboard.
|
|
func (h *AuthHandler) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
w.Header().Set("HX-Redirect", "/")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
renderOnboardingError(w, "Cannot parse form data.")
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
department := strings.TrimSpace(r.FormValue("department"))
|
|
if name == "" {
|
|
renderOnboardingError(w, "Name is required.")
|
|
return
|
|
}
|
|
if department == "" {
|
|
department = "-"
|
|
}
|
|
|
|
if err := database.UpdateUserOnboarding(h.DB, userID, name, department); err != nil {
|
|
renderOnboardingError(w, "Failed to save. Please try again.")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("HX-Redirect", "/dashboard")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Profile handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ProfilePage renders the profile editor with the user's current name and
|
|
// department pre-filled. Requires onboarding to be completed first.
|
|
func (h *AuthHandler) ProfilePage(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
w.Header().Set("HX-Redirect", "/")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
user, _ := database.GetUserByID(h.DB, userID)
|
|
if user == nil || !user.Onboarded {
|
|
w.Header().Set("HX-Redirect", "/onboarding")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
tmpl := getTemplate("onboarding.html")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, map[string]string{
|
|
"Name": user.Name,
|
|
"Department": user.Department,
|
|
"Editing": "true",
|
|
}); err != nil {
|
|
log.Printf("ERROR [%s] handlers: ProfilePage: execute template: %v", time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|
|
|
|
// SaveProfile updates the user's name and department, then redirects to the
|
|
// dashboard. Reuses the same DB call as onboarding.
|
|
func (h *AuthHandler) SaveProfile(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
w.Header().Set("HX-Redirect", "/")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
renderOnboardingError(w, "Cannot parse form data.")
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
department := strings.TrimSpace(r.FormValue("department"))
|
|
if name == "" {
|
|
renderOnboardingError(w, "Name is required.")
|
|
return
|
|
}
|
|
if department == "" {
|
|
department = "-"
|
|
}
|
|
|
|
if err := database.UpdateUserOnboarding(h.DB, userID, name, department); err != nil {
|
|
renderOnboardingError(w, "Failed to save. Please try again.")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("HX-Redirect", "/dashboard")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func renderOnboardingError(w http.ResponseWriter, message string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div id="onboarding-error" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div>`,
|
|
template.HTMLEscapeString(message))
|
|
}
|