inboxer/internal/handlers/auth.go
cclohmar ca970104ee chore: initial commit — ExpenseFlow AI-Powered Expense Tracker
- Passwordless email OTP authentication
- Event-based expense tracking with HTMX UI
- AI receipt extraction via DeepSeek Vision API
- CSV/PDF report generation with email filing
- PWA with service worker and manifest
- Mobile-first responsive design
- SQLite database with auto-migration
2026-05-29 19:43:30 +00:00

298 lines
12 KiB
Go

// 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, `<div class="error-message" style="color: #dc2626; margin-bottom: 1rem;">%s</div>`, 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(`
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="outerHTML">
<input type="hidden" name="email" value="{{.Email}}">
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_1" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_2" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_3" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_4" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_5" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
</div>
<button type="submit" style="width: 100%; padding: 0.75rem; background-color: #10b981; color: white; border: none; border-radius: 0.5rem; font-size: 1rem; cursor: pointer;">
Verify Code
</button>
</form>
`))
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()
}