The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName and Subsonic's authenticate both called auth.Authenticate with no limiting, so an attacker brute-forced through those instead: the same password, the same argon2, no ceiling. The limiter moves from auth.Handlers onto *Authenticator, which all three surfaces already hold, so they share one budget and splitting guesses across surfaces gains nothing. main.go builds one authenticator and hands it to both compat layers, so the sharing is real rather than intended. Subsonic needs care: it has no login step, authenticate runs on every request, so a naive check would charge playback against the login budget and lock a listener out mid-album. The limiter is consulted only when a credential was actually supplied, and a success or an uncredentialed request spends nothing. Verified red-green on all three: removing the Jellyfin gate fails TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the Subsonic credential guard fails TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or ResetLogin fails the auth tests. Known gap, unchanged: the limiter is in-process, so it resets on restart and does not span replicas. Fine for one binary against one SQLite file.
309 lines
9.9 KiB
Go
309 lines
9.9 KiB
Go
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
|
|
}
|