package auth import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "html/template" "net/http" "strconv" "strings" "sync" "time" "github.com/go-chi/chi/v5" "llm-gateway/internal/storage" ) type Handlers struct { store *Store sessionSecret string loginLimiter *loginRateLimiter auditLogger *storage.AuditLogger } func NewHandlers(store *Store, sessionSecret string) *Handlers { return &Handlers{ store: store, sessionSecret: sessionSecret, loginLimiter: newLoginRateLimiter(), } } func (h *Handlers) SetAuditLogger(al *storage.AuditLogger) { h.auditLogger = al } func (h *Handlers) audit(r *http.Request, action, targetType, targetID, details string) { if h.auditLogger == nil { return } user := UserFromContext(r.Context()) var userID int64 var username string if user != nil { userID = user.ID username = user.Username } ip := r.RemoteAddr if fwd := r.Header.Get("X-Real-IP"); fwd != "" { ip = fwd } h.auditLogger.Log(storage.AuditEntry{ UserID: userID, Username: username, Action: action, TargetType: targetType, TargetID: targetID, Details: details, IPAddress: ip, }) } // Login brute-force protection type loginRateLimiter struct { mu sync.Mutex attempts map[string][]time.Time } func newLoginRateLimiter() *loginRateLimiter { return &loginRateLimiter{attempts: make(map[string][]time.Time)} } func (l *loginRateLimiter) allow(ip string) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() cutoff := now.Add(-1 * time.Minute) // Clean old entries recent := l.attempts[ip][:0] for _, t := range l.attempts[ip] { if t.After(cutoff) { recent = append(recent, t) } } l.attempts[ip] = recent if len(recent) >= 5 { return false } l.attempts[ip] = append(l.attempts[ip], now) return true } func (h *Handlers) Status(w http.ResponseWriter, r *http.Request) { initialized := h.store.HasAnyUser() resp := map[string]any{ "initialized": initialized, "logged_in": false, } cookie, err := r.Cookie(sessionCookieName) if err == nil && cookie.Value != "" { sess, err := h.store.GetSession(cookie.Value) if err == nil { user, err := h.store.GetUserByID(sess.UserID) if err == nil { resp["logged_in"] = true resp["user"] = map[string]any{ "id": user.ID, "username": user.Username, "is_admin": user.IsAdmin, "totp_enabled": user.TOTPEnabled, } } } } writeJSON(w, resp) } func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) if h.store.HasAnyUser() { if htmx { writeHTMXError(w, "already initialized") return } writeError(w, http.StatusBadRequest, "already initialized") return } var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.Username == "" || req.Password == "" { if htmx { writeHTMXError(w, "username and password required") return } writeError(w, http.StatusBadRequest, "username and password required") return } if len(req.Password) < 8 { if htmx { writeHTMXError(w, "password must be at least 8 characters") return } writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } user, err := h.store.CreateUser(req.Username, req.Password, true) if err != nil { msg := "failed to create user: " + err.Error() if htmx { writeHTMXError(w, msg) return } writeError(w, http.StatusInternalServerError, msg) return } // Auto-login sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour) if err != nil { if htmx { writeHTMXError(w, "failed to create session") return } writeError(w, http.StatusInternalServerError, "failed to create session") return } h.audit(r, "auth.setup", "user", fmt.Sprintf("%d", user.ID), "initial setup") h.setSessionCookie(w, sessionID) if htmx { writeHTMXRedirect(w, "/dashboard") return } writeJSON(w, map[string]any{ "user": map[string]any{ "id": user.ID, "username": user.Username, "is_admin": user.IsAdmin, }, }) } func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) ip := r.RemoteAddr if fwd := r.Header.Get("X-Real-IP"); fwd != "" { ip = fwd } if !h.loginLimiter.allow(ip) { if htmx { writeHTMXError(w, "too many login attempts, try again in a minute") return } writeError(w, http.StatusTooManyRequests, "too many login attempts, try again in a minute") return } var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } user, err := h.store.GetUserByUsername(req.Username) if err != nil { if htmx { writeHTMXError(w, "invalid credentials") return } writeError(w, http.StatusUnauthorized, "invalid credentials") return } if !h.store.CheckPassword(user, req.Password) { if htmx { writeHTMXError(w, "invalid credentials") return } writeError(w, http.StatusUnauthorized, "invalid credentials") return } if user.TOTPEnabled { // Set pending cookie for TOTP step pending := h.signPendingToken(user.ID) http.SetCookie(w, &http.Cookie{ Name: "llmgw_pending", Value: pending, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 300, // 5 minutes }) if htmx { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `
`) return } writeJSON(w, map[string]any{"require_totp": true}) return } sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour) if err != nil { if htmx { writeHTMXError(w, "failed to create session") return } writeError(w, http.StatusInternalServerError, "failed to create session") return } h.audit(r, "auth.login", "user", fmt.Sprintf("%d", user.ID), user.Username) h.setSessionCookie(w, sessionID) if htmx { writeHTMXRedirect(w, "/dashboard") return } writeJSON(w, map[string]any{ "require_totp": false, "user": map[string]any{ "id": user.ID, "username": user.Username, "is_admin": user.IsAdmin, }, }) } func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) cookie, err := r.Cookie("llmgw_pending") if err != nil || cookie.Value == "" { if htmx { writeHTMXError(w, "no pending login") return } writeError(w, http.StatusBadRequest, "no pending login") return } userID, err := h.verifyPendingToken(cookie.Value) if err != nil { if htmx { writeHTMXError(w, "invalid or expired pending login") return } writeError(w, http.StatusBadRequest, "invalid or expired pending login") return } var req struct { Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } user, err := h.store.GetUserByID(userID) if err != nil { if htmx { writeHTMXError(w, "user not found") return } writeError(w, http.StatusBadRequest, "user not found") return } if !ValidateTOTPCode(user.TOTPSecret, req.Code) { if htmx { writeHTMXError(w, "invalid TOTP code") return } writeError(w, http.StatusUnauthorized, "invalid TOTP code") return } // Clear pending cookie http.SetCookie(w, &http.Cookie{ Name: "llmgw_pending", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, }) sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour) if err != nil { if htmx { writeHTMXError(w, "failed to create session") return } writeError(w, http.StatusInternalServerError, "failed to create session") return } h.setSessionCookie(w, sessionID) if htmx { writeHTMXRedirect(w, "/dashboard") return } writeJSON(w, map[string]any{ "user": map[string]any{ "id": user.ID, "username": user.Username, "is_admin": user.IsAdmin, }, }) } func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { h.audit(r, "auth.logout", "", "", "") cookie, err := r.Cookie(sessionCookieName) if err == nil { h.store.DeleteSession(cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1, }) if isHTMX(r) { writeHTMXRedirect(w, "/login") return } writeJSON(w, map[string]string{"status": "ok"}) } func (h *Handlers) Me(w http.ResponseWriter, r *http.Request) { user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } writeJSON(w, map[string]any{ "id": user.ID, "username": user.Username, "is_admin": user.IsAdmin, "totp_enabled": user.TOTPEnabled, }) } func (h *Handlers) TOTPSetup(w http.ResponseWriter, r *http.Request) { user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } key, err := GenerateTOTPKey(user.Username) if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate TOTP key") return } if err := h.store.SetTOTPSecret(user.ID, key.Secret()); err != nil { writeError(w, http.StatusInternalServerError, "failed to save TOTP secret") return } writeJSON(w, map[string]string{ "secret": key.Secret(), "uri": key.URL(), }) } func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } var req struct { Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } // Re-fetch user to get latest TOTP secret user, err := h.store.GetUserByID(user.ID) if err != nil { if htmx { writeHTMXError(w, "failed to fetch user") return } writeError(w, http.StatusInternalServerError, "failed to fetch user") return } if user.TOTPSecret == "" { if htmx { writeHTMXError(w, "TOTP not set up yet") return } writeError(w, http.StatusBadRequest, "TOTP not set up, call /api/auth/totp/setup first") return } if !ValidateTOTPCode(user.TOTPSecret, req.Code) { if htmx { writeHTMXError(w, "invalid TOTP code") return } writeError(w, http.StatusBadRequest, "invalid TOTP code") return } if err := h.store.EnableTOTP(user.ID); err != nil { if htmx { writeHTMXError(w, "failed to enable TOTP") return } writeError(w, http.StatusInternalServerError, "failed to enable TOTP") return } h.audit(r, "totp.enable", "user", fmt.Sprintf("%d", user.ID), "") if htmx { // Trigger settings page reload to show updated TOTP status w.Header().Set("HX-Trigger", "settingsRefresh") writeHTMXSuccess(w, "Two-factor authentication enabled") return } writeJSON(w, map[string]string{"status": "totp_enabled"}) } func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } if err := h.store.DisableTOTP(user.ID); err != nil { if htmx { writeHTMXError(w, "failed to disable TOTP") return } writeError(w, http.StatusInternalServerError, "failed to disable TOTP") return } h.audit(r, "totp.disable", "user", fmt.Sprintf("%d", user.ID), "") if htmx { w.Header().Set("HX-Trigger", "settingsRefresh") writeHTMXSuccess(w, "Two-factor authentication disabled") return } writeJSON(w, map[string]string{"status": "totp_disabled"}) } // User management (admin only) func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) { users, err := h.store.ListUsers() if err != nil { writeError(w, http.StatusInternalServerError, "failed to list users") return } // Strip sensitive fields type safeUser struct { ID int64 `json:"id"` Username string `json:"username"` IsAdmin bool `json:"is_admin"` TOTPEnabled bool `json:"totp_enabled"` CreatedAt int64 `json:"created_at"` } result := make([]safeUser, len(users)) for i, u := range users { result[i] = safeUser{ ID: u.ID, Username: u.Username, IsAdmin: u.IsAdmin, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt, } } writeJSON(w, result) } func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) var req struct { Username string `json:"username"` Password string `json:"password"` IsAdmin bool `json:"is_admin"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.Username == "" || req.Password == "" { if htmx { writeHTMXError(w, "username and password required") return } writeError(w, http.StatusBadRequest, "username and password required") return } if len(req.Password) < 8 { if htmx { writeHTMXError(w, "password must be at least 8 characters") return } writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } user, err := h.store.CreateUser(req.Username, req.Password, req.IsAdmin) if err != nil { msg := "failed to create user: " + err.Error() if htmx { writeHTMXError(w, msg) return } writeError(w, http.StatusInternalServerError, msg) return } h.audit(r, "user.create", "user", fmt.Sprintf("%d", user.ID), user.Username) if htmx { writeHTMXRefresh(w) return } writeJSON(w, map[string]any{ "id": user.ID, "username": user.Username, "is_admin": user.IsAdmin, }) } func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { if htmx { writeHTMXError(w, "invalid user ID") return } writeError(w, http.StatusBadRequest, "invalid user ID") return } // Prevent deleting yourself user := UserFromContext(r.Context()) if user != nil && user.ID == id { if htmx { writeHTMXError(w, "cannot delete yourself") return } writeError(w, http.StatusBadRequest, "cannot delete yourself") return } if err := h.store.DeleteUser(id); err != nil { if htmx { writeHTMXError(w, err.Error()) return } writeError(w, http.StatusBadRequest, err.Error()) return } h.audit(r, "user.delete", "user", idStr, "") if htmx { writeHTMXRefresh(w) return } writeJSON(w, map[string]string{"status": "deleted"}) } // API Token management func (h *Handlers) ListTokens(w http.ResponseWriter, r *http.Request) { user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } var userID int64 if !user.IsAdmin { userID = user.ID } // userID=0 means list all (admin) tokens, err := h.store.ListAPITokens(userID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list tokens") return } if tokens == nil { tokens = []APIToken{} } writeJSON(w, tokens) } func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } var req struct { Name string `json:"name"` RateLimitRPM int `json:"rate_limit_rpm"` DailyBudgetUSD float64 `json:"daily_budget_usd"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.Name == "" { if htmx { writeHTMXError(w, "name is required") return } writeError(w, http.StatusBadRequest, "name is required") return } // RateLimitRPM: 0 = unlimited, negative treated as 0 if req.RateLimitRPM < 0 { req.RateLimitRPM = 0 } plainKey, token, err := h.store.CreateAPIToken(user.ID, req.Name, req.RateLimitRPM, req.DailyBudgetUSD) if err != nil { msg := "failed to create token: " + err.Error() if htmx { writeHTMXError(w, msg) return } writeError(w, http.StatusInternalServerError, msg) return } h.audit(r, "token.create", "token", fmt.Sprintf("%d", token.ID), req.Name) if htmx { // Return HTML with the key display and trigger page refresh w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Trigger", "tokenCreated") escaped := template.HTMLEscapeString(plainKey) fmt.Fprintf(w, `
Token created! Copy the key below — it won't be shown again.
%s
`, escaped) return } writeJSON(w, map[string]any{ "key": plainKey, "token": token, }) } func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { if htmx { writeHTMXError(w, "invalid token ID") return } writeError(w, http.StatusBadRequest, "invalid token ID") return } // Non-admin can only delete own tokens if !user.IsAdmin { token, err := h.store.GetAPIToken(id) if err != nil { if htmx { writeHTMXError(w, "token not found") return } writeError(w, http.StatusNotFound, "token not found") return } if token.UserID != user.ID { if htmx { writeHTMXError(w, "not your token") return } writeError(w, http.StatusForbidden, "not your token") return } } if err := h.store.DeleteAPIToken(id); err != nil { if htmx { writeHTMXError(w, "failed to delete token") return } writeError(w, http.StatusInternalServerError, "failed to delete token") return } h.audit(r, "token.delete", "token", idStr, "") if htmx { writeHTMXRefresh(w) return } writeJSON(w, map[string]string{"status": "deleted"}) } // Self-service endpoints func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } var req struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.NewPassword == "" || len(req.NewPassword) < 8 { if htmx { writeHTMXError(w, "new password must be at least 8 characters") return } writeError(w, http.StatusBadRequest, "new password must be at least 8 characters") return } // Re-fetch user to get password hash user, err := h.store.GetUserByID(user.ID) if err != nil { if htmx { writeHTMXError(w, "failed to fetch user") return } writeError(w, http.StatusInternalServerError, "failed to fetch user") return } if !h.store.CheckPassword(user, req.CurrentPassword) { if htmx { writeHTMXError(w, "current password is incorrect") return } writeError(w, http.StatusUnauthorized, "current password is incorrect") return } if err := h.store.UpdatePassword(user.ID, req.NewPassword); err != nil { if htmx { writeHTMXError(w, "failed to update password") return } writeError(w, http.StatusInternalServerError, "failed to update password") return } h.audit(r, "password.change", "user", fmt.Sprintf("%d", user.ID), "") if htmx { writeHTMXSuccess(w, "Password updated") return } writeJSON(w, map[string]string{"status": "password_updated"}) } func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } var req struct { NewUsername string `json:"new_username"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.NewUsername == "" { if htmx { writeHTMXError(w, "username is required") return } writeError(w, http.StatusBadRequest, "username is required") return } // Check uniqueness existing, err := h.store.GetUserByUsername(req.NewUsername) if err == nil && existing.ID != user.ID { if htmx { writeHTMXError(w, "username already taken") return } writeError(w, http.StatusConflict, "username already taken") return } if err := h.store.UpdateUsername(user.ID, req.NewUsername); err != nil { if htmx { writeHTMXError(w, "failed to update username") return } writeError(w, http.StatusInternalServerError, "failed to update username") return } h.audit(r, "username.change", "user", fmt.Sprintf("%d", user.ID), req.NewUsername) if htmx { writeHTMXSuccess(w, "Username updated") return } writeJSON(w, map[string]string{"status": "username_updated"}) } func (h *Handlers) ChangeEmail(w http.ResponseWriter, r *http.Request) { htmx := isHTMX(r) user := UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } var req struct { Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if htmx { writeHTMXError(w, "invalid request") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.store.UpdateEmail(user.ID, req.Email); err != nil { if htmx { writeHTMXError(w, "failed to update email") return } writeError(w, http.StatusInternalServerError, "failed to update email") return } if htmx { writeHTMXSuccess(w, "Email updated") return } writeJSON(w, map[string]string{"status": "email_updated"}) } // Helpers func (h *Handlers) setSessionCookie(w http.ResponseWriter, sessionID string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sessionID, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: sessionTTLDays * 24 * 60 * 60, }) } func (h *Handlers) signPendingToken(userID int64) string { data := fmt.Sprintf("%d:%d", userID, time.Now().Unix()) mac := hmac.New(sha256.New, []byte(h.sessionSecret)) mac.Write([]byte(data)) sig := hex.EncodeToString(mac.Sum(nil)) return data + ":" + sig } func (h *Handlers) verifyPendingToken(token string) (int64, error) { parts := strings.SplitN(token, ":", 3) if len(parts) != 3 { return 0, fmt.Errorf("invalid format") } userID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { return 0, fmt.Errorf("invalid user ID") } ts, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { return 0, fmt.Errorf("invalid timestamp") } // Check expiry (5 minutes) if time.Now().Unix()-ts > 300 { return 0, fmt.Errorf("expired") } // Verify HMAC data := parts[0] + ":" + parts[1] mac := hmac.New(sha256.New, []byte(h.sessionSecret)) mac.Write([]byte(data)) expectedSig := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(parts[2]), []byte(expectedSig)) { return 0, fmt.Errorf("invalid signature") } return userID, nil } func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } func writeHTMXError(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(msg)) } func writeHTMXSuccess(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(msg)) } func writeHTMXRedirect(w http.ResponseWriter, url string) { w.Header().Set("HX-Redirect", url) w.WriteHeader(http.StatusOK) } func writeHTMXRefresh(w http.ResponseWriter) { w.Header().Set("HX-Refresh", "true") w.WriteHeader(http.StatusOK) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) }