diff --git a/llm-gateway/cmd/gateway/main.go b/llm-gateway/cmd/gateway/main.go index 836781a..c8bbd06 100644 --- a/llm-gateway/cmd/gateway/main.go +++ b/llm-gateway/cmd/gateway/main.go @@ -141,6 +141,7 @@ func main() { // Audit logger auditLogger := storage.NewAuditLogger(db) + auditLogger.OnWrite = sseBroker.Notify authHandlers.SetAuditLogger(auditLogger) // Debug logger @@ -149,6 +150,7 @@ func main() { debugDataDir = filepath.Dir(cfg.Database.Path) } debugLogger := storage.NewDebugLogger(db, cfg.Debug.Enabled, debugDataDir) + debugLogger.OnWrite = sseBroker.Notify // Seed default admin seedDefaultAdmin(cfg, authStore) @@ -248,6 +250,11 @@ func main() { r.Post("/api/auth/setup", authHandlers.Setup) r.Post("/api/auth/login/totp", authHandlers.LoginTOTP) + // Favicon (prevent 401 noise in browser console) + r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + // Root redirect r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/dashboard", http.StatusFound) diff --git a/llm-gateway/internal/auth/handlers.go b/llm-gateway/internal/auth/handlers.go index 59ca0c7..7585587 100644 --- a/llm-gateway/internal/auth/handlers.go +++ b/llm-gateway/internal/auth/handlers.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "html/template" "net/http" "strconv" "strings" @@ -124,7 +125,13 @@ func (h *Handlers) Status(w http.ResponseWriter, r *http.Request) { } 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 } @@ -134,27 +141,48 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { 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 { - writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + 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 } @@ -162,6 +190,10 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { 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, @@ -172,11 +204,17 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { } 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 } @@ -186,17 +224,29 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { 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 } @@ -212,12 +262,27 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { 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 } @@ -225,6 +290,10 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { 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{ @@ -236,14 +305,24 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { } 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 } @@ -252,17 +331,29 @@ func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) { 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 } @@ -278,11 +369,19 @@ func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) { 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, @@ -308,6 +407,10 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { MaxAge: -1, }) + if isHTMX(r) { + writeHTMXRedirect(w, "/login") + return + } writeJSON(w, map[string]string{"status": "ok"}) } @@ -350,6 +453,7 @@ func (h *Handlers) TOTPSetup(w http.ResponseWriter, r *http.Request) { } 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") @@ -360,6 +464,10 @@ func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) { 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 } @@ -367,30 +475,53 @@ func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) { // 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") @@ -398,11 +529,20 @@ func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) { } 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"}) } @@ -437,31 +577,53 @@ func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) { } 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 { - writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + 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, @@ -470,9 +632,14 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) { } 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 } @@ -480,16 +647,28 @@ func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) { // 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"}) } @@ -520,6 +699,7 @@ func (h *Handlers) ListTokens(w http.ResponseWriter, r *http.Request) { } 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") @@ -532,10 +712,18 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) { 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 } @@ -546,11 +734,25 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) { plainKey, token, err := h.store.CreateAPIToken(user.ID, req.Name, req.RateLimitRPM, req.DailyBudgetUSD) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create token: "+err.Error()) + 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, `%s