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