package auth import ( "errors" "log/slog" "net/http" "strconv" "time" "github.com/mithral/vellum/internal/httpx" ) // Handlers serves the authentication and user-management endpoints. type Handlers struct { auth *Authenticator log *slog.Logger } // NewHandlers builds the auth HTTP layer. func NewHandlers(a *Authenticator, log *slog.Logger) *Handlers { return &Handlers{auth: a, log: log} } // Register mounts the routes onto the shared mux. func (h *Handlers) Register(mux *http.ServeMux) { // Public. mux.HandleFunc("POST /api/v1/auth/login", h.handleLogin) mux.HandleFunc("POST /api/v1/auth/logout", h.handleLogout) mux.HandleFunc("GET /api/v1/auth/me", h.handleMe) // Authenticated. mux.Handle("POST /api/v1/auth/password", h.auth.Require(h.handleChangePassword)) // Admin: user management. mux.Handle("GET /api/v1/users", h.auth.RequireAdmin(http.HandlerFunc(h.handleListUsers))) mux.Handle("POST /api/v1/users", h.auth.RequireAdmin(http.HandlerFunc(h.handleCreateUser))) mux.Handle("GET /api/v1/users/{id}/grants", h.auth.RequireAdmin(http.HandlerFunc(h.handleListGrants))) mux.Handle("PUT /api/v1/users/{id}/grants/{libraryID}", h.auth.RequireAdmin(http.HandlerFunc(h.handlePutGrant))) mux.Handle("DELETE /api/v1/users/{id}/grants/{libraryID}", h.auth.RequireAdmin(http.HandlerFunc(h.handleDeleteGrant))) } // ── types ────────────────────────────────────────────────────────────── // userDTO is the client-facing shape of a user. It never includes a hash. type userDTO struct { ID int64 `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` IsAdmin bool `json:"is_admin"` CreatedAt string `json:"created_at"` } func toDTO(u User) userDTO { return userDTO{ ID: u.ID, Username: u.Username, DisplayName: u.DisplayName, IsAdmin: u.IsAdmin, CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339), } } // ── handlers ─────────────────────────────────────────────────────────── func (h *Handlers) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` Password string `json:"password"` DeviceLabel string `json:"device_label"` } if err := httpx.DecodeJSON(r, &req); err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } if req.Username == "" || req.Password == "" { httpx.WriteError(w, http.StatusBadRequest, "username and password are required") return } ip := h.auth.ClientIP(r) if !h.auth.AllowLogin(ip, req.Username) { h.log.Warn("login rate limited", "ip", ip, "username", req.Username) httpx.WriteError(w, http.StatusTooManyRequests, "too many login attempts, try again later") return } u, err := Authenticate(r.Context(), h.auth.DB, req.Username, req.Password) if err != nil { if errors.Is(err, ErrInvalidCredentials) { h.log.Info("login failed", "ip", ip, "username", req.Username) // Same message for unknown user and wrong password. httpx.WriteError(w, http.StatusUnauthorized, "invalid username or password") return } h.log.Error("login error", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } token, err := CreateSession(r.Context(), h.auth.DB, u.ID, h.auth.CookieTTL, req.DeviceLabel, ip, r.UserAgent()) if err != nil { h.log.Error("create session", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } h.auth.SetSessionCookie(w, token, time.Now().Add(h.auth.CookieTTL)) h.auth.ResetLogin(ip, req.Username) h.log.Info("login ok", "user", u.Username, "ip", ip) httpx.WriteJSON(w, http.StatusOK, map[string]any{ "user": toDTO(u), "expires_in": int(h.auth.CookieTTL.Seconds()), }) } func (h *Handlers) handleLogout(w http.ResponseWriter, r *http.Request) { if token, err := h.auth.TokenFromRequest(r); err == nil { if err := DeleteSession(r.Context(), h.auth.DB, token); err != nil { h.log.Warn("delete session", "err", err) } } h.auth.ClearSessionCookie(w) httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) } func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) { u, ok := CurrentUser(r.Context()) if !ok { httpx.WriteError(w, http.StatusUnauthorized, "not authenticated") return } libs, err := VisibleLibraryIDs(r.Context(), h.auth.DB, u) if err != nil { h.log.Error("visible libraries", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } if libs == nil { libs = []int64{} } httpx.WriteJSON(w, http.StatusOK, map[string]any{ "user": toDTO(u), "visible_libraries": libs, }) } func (h *Handlers) handleChangePassword(w http.ResponseWriter, r *http.Request, u User) { var req struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } if err := httpx.DecodeJSON(r, &req); err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } if len(req.NewPassword) < 8 { httpx.WriteError(w, http.StatusBadRequest, "new password must be at least 8 characters") return } if _, err := Authenticate(r.Context(), h.auth.DB, u.Username, req.CurrentPassword); err != nil { httpx.WriteError(w, http.StatusUnauthorized, "current password is incorrect") return } if err := SetPassword(r.Context(), h.auth.DB, u.ID, req.NewPassword); err != nil { h.log.Error("change password", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } // SetPassword revoked every session, including this one. h.auth.ClearSessionCookie(w) httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "password changed, please log in again"}) } // ── admin: users ─────────────────────────────────────────────────────── func (h *Handlers) handleListUsers(w http.ResponseWriter, r *http.Request) { users, err := ListUsers(r.Context(), h.auth.DB) if err != nil { h.log.Error("list users", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } out := make([]userDTO, 0, len(users)) for _, u := range users { out = append(out, toDTO(u)) } httpx.WriteJSON(w, http.StatusOK, map[string]any{"users": out}) } func (h *Handlers) handleCreateUser(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` DisplayName string `json:"display_name"` Password string `json:"password"` IsAdmin bool `json:"is_admin"` } if err := httpx.DecodeJSON(r, &req); err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } if len(req.Password) < 8 { httpx.WriteError(w, http.StatusBadRequest, "password must be at least 8 characters") return } u, err := CreateUser(r.Context(), h.auth.DB, req.Username, req.DisplayName, req.Password, req.IsAdmin) switch { case errors.Is(err, ErrUserExists): httpx.WriteError(w, http.StatusConflict, "username already taken") return case errors.Is(err, ErrInvalidUsername): httpx.WriteError(w, http.StatusBadRequest, err.Error()) return case err != nil: h.log.Error("create user", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } h.log.Info("user created", "username", u.Username, "admin", u.IsAdmin) httpx.WriteJSON(w, http.StatusCreated, map[string]any{"user": toDTO(u)}) } // ── admin: grants ────────────────────────────────────────────────────── func (h *Handlers) handleListGrants(w http.ResponseWriter, r *http.Request) { userID, ok := pathInt(w, r, "id") if !ok { return } grants, err := LibraryGrants(r.Context(), h.auth.DB, userID) if err != nil { h.log.Error("list grants", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"grants": grants}) } func (h *Handlers) handlePutGrant(w http.ResponseWriter, r *http.Request) { userID, ok := pathInt(w, r, "id") if !ok { return } libraryID, ok := pathInt(w, r, "libraryID") if !ok { return } req := struct { CanRead *bool `json:"can_read"` CanDownload *bool `json:"can_download"` }{} if err := httpx.DecodeJSON(r, &req); err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } canRead := true if req.CanRead != nil { canRead = *req.CanRead } canDownload := false if req.CanDownload != nil { canDownload = *req.CanDownload } if err := GrantLibrary(r.Context(), h.auth.DB, userID, libraryID, canRead, canDownload); err != nil { h.log.Error("grant library", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } httpx.WriteJSON(w, http.StatusOK, map[string]any{ "user_id": userID, "library_id": libraryID, "can_read": canRead, "can_download": canDownload, }) } func (h *Handlers) handleDeleteGrant(w http.ResponseWriter, r *http.Request) { userID, ok := pathInt(w, r, "id") if !ok { return } libraryID, ok := pathInt(w, r, "libraryID") if !ok { return } if err := RevokeLibrary(r.Context(), h.auth.DB, userID, libraryID); err != nil { h.log.Error("revoke library", "err", err) httpx.WriteError(w, http.StatusInternalServerError, "internal server error") return } w.WriteHeader(http.StatusNoContent) } func pathInt(w http.ResponseWriter, r *http.Request, name string) (int64, bool) { raw := r.PathValue(name) n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n <= 0 { httpx.WriteError(w, http.StatusBadRequest, "invalid "+name) return 0, false } return n, true }