Vellum/internal/auth/handlers.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

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 := 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)
}
}
}