Some checks failed
CI / ci (push) Has been cancelled
ClientIP used r.RemoteAddr and ignored X-Forwarded-For. That was right about not trusting the header from an untrusted peer, but it is only half the design: behind a proxy every request arrives from the proxy, so the limiter key in handlers.go was "<proxy-ip>|<username>" for every client on the internet. One attacker locked out a username for everyone, an attacker rotating usernames never hit a limit, and sessions.ip recorded the proxy so the device list was useless. - VELLUM_TRUSTED_PROXIES is a CIDR list. X-Forwarded-For is honoured only when RemoteAddr is inside it, and the chain is walked right-to-left so the first address that is not itself a trusted proxy wins. Everything to its left was appended by a client we do not control, so a spoofed leftmost entry is discarded. - VELLUM_PUBLIC_URL is the external origin. CookieSecure now defaults to true when it is https://, so a TLS deployment that forgets VELLUM_COOKIE_SECURE no longer ships a plaintext session cookie. - A malformed CIDR fails the boot rather than being skipped: silently dropping a proxy network widens the trust boundary, which is the exact failure this setting exists to prevent. ClientIP became a method on Authenticator so it can see the trusted set, which is why handlers.go and main.go changed as well. Verified red-green: neutering the trusted-peer path fails five ClientIP tests, including the spoofed-leftmost case. Known gap, pre-existing and not fixed here: this limiter still covers only /api/v1/auth/login. Jellyfin AuthenticateByName and Subsonic auth call auth.Authenticate with no limiting, so the same brute force is available through those two surfaces.
371 lines
11 KiB
Go
371 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/httpx"
|
|
)
|
|
|
|
// Handlers serves the authentication and user-management endpoints.
|
|
type Handlers struct {
|
|
auth *Authenticator
|
|
log *slog.Logger
|
|
limiter *loginLimiter
|
|
}
|
|
|
|
// NewHandlers builds the auth HTTP layer.
|
|
func NewHandlers(a *Authenticator, log *slog.Logger) *Handlers {
|
|
return &Handlers{
|
|
auth: a,
|
|
log: log,
|
|
limiter: newLoginLimiter(10, 5*time.Minute),
|
|
}
|
|
}
|
|
|
|
// 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.limiter.allow(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.limiter.reset(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
|
|
}
|
|
|
|
// ── login rate limiting ────────────────────────────────────────────────
|
|
|
|
// loginLimiter is a fixed-window counter keyed by "ip|username".
|
|
//
|
|
// In-memory is the right scope for a single-process server: it cannot be
|
|
// bypassed by restarting a client, and there is no second process to share it
|
|
// with. Persisting failed-login counts would be overkill for a LAN server.
|
|
type loginLimiter struct {
|
|
mu sync.Mutex
|
|
hits map[string]*hitWindow
|
|
max int
|
|
window time.Duration
|
|
}
|
|
|
|
type hitWindow struct {
|
|
count int
|
|
reset time.Time
|
|
}
|
|
|
|
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
|
|
return &loginLimiter{hits: make(map[string]*hitWindow), max: max, window: window}
|
|
}
|
|
|
|
func (l *loginLimiter) allow(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
h, ok := l.hits[key]
|
|
if !ok || now.After(h.reset) {
|
|
l.hits[key] = &hitWindow{count: 1, reset: now.Add(l.window)}
|
|
l.pruneLocked(now)
|
|
return true
|
|
}
|
|
h.count++
|
|
return h.count <= l.max
|
|
}
|
|
|
|
func (l *loginLimiter) reset(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.hits, key)
|
|
}
|
|
|
|
// pruneLocked drops expired windows so the map cannot grow without bound.
|
|
func (l *loginLimiter) pruneLocked(now time.Time) {
|
|
if len(l.hits) < 1000 {
|
|
return
|
|
}
|
|
for k, h := range l.hits {
|
|
if now.After(h.reset) {
|
|
delete(l.hits, k)
|
|
}
|
|
}
|
|
}
|