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.
This commit is contained in:
mithral 2026-10-09 16:31:24 -04:00
parent eb1ad6d920
commit 9772e55c73
14 changed files with 1841 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/migrations" "github.com/mithral/vellum/migrations"
@ -53,6 +54,20 @@ func run() error {
} }
api := httpapi.New(db, log, version) api := httpapi.New(db, log, version)
authenticator := auth.NewAuthenticator(db, cfg.CookieName, cfg.SessionTTL, cfg.CookieSecure, log)
if !cfg.CookieSecure {
log.Warn("VELLUM_COOKIE_SECURE is false; session cookies will be sent over plain HTTP. " +
"Set it to true when serving over TLS.")
}
if err := auth.EnsureAdminFromEnv(context.Background(), db, log, cfg.AdminUser, cfg.AdminPassword); err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
auth.NewHandlers(authenticator, log).Register(api.Mux())
api.SetAuth(authenticator)
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: api.Handler(), Handler: api.Handler(),

7
go.mod
View file

@ -2,7 +2,10 @@ module github.com/mithral/vellum
go 1.26.8 go 1.26.8
require modernc.org/sqlite v1.60.1 require (
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.60.1
)
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
@ -10,7 +13,7 @@ require (
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.49.0 // indirect
modernc.org/libc v1.77.1 // indirect modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect modernc.org/memory v1.12.1 // indirect

6
go.sum
View file

@ -12,12 +12,14 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=

454
internal/auth/auth_test.go Normal file
View file

@ -0,0 +1,454 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "auth.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func mustCreateUser(t *testing.T, db *sql.DB, username string, admin bool) User {
t.Helper()
u, err := CreateUser(context.Background(), db, username, "", "password123", admin)
if err != nil {
t.Fatalf("CreateUser(%s): %v", username, err)
}
return u
}
func mustCreateLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
t.Helper()
var id int64
err := db.QueryRowContext(context.Background(),
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`,
name, kind, root).Scan(&id)
if err != nil {
t.Fatalf("insert library: %v", err)
}
return id
}
// ── users ──────────────────────────────────────────────────────────────
func TestCreateUserAndAuthenticate(t *testing.T) {
db := testDB(t)
ctx := context.Background()
u, err := CreateUser(ctx, db, "alice", "Alice", "hunter2hunter2", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if u.DisplayName != "Alice" {
t.Errorf("DisplayName = %q", u.DisplayName)
}
got, err := Authenticate(ctx, db, "alice", "hunter2hunter2")
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if got.ID != u.ID {
t.Errorf("authenticated id = %d, want %d", got.ID, u.ID)
}
// Usernames are case-insensitive.
if _, err := Authenticate(ctx, db, "ALICE", "hunter2hunter2"); err != nil {
t.Errorf("case-insensitive login failed: %v", err)
}
}
func TestAuthenticateRejectsBadCredentials(t *testing.T) {
db := testDB(t)
ctx := context.Background()
mustCreateUser(t, db, "bob", false)
for _, tc := range []struct{ user, pass string }{
{"bob", "wrong"},
{"nobody", "password123"},
} {
_, err := Authenticate(ctx, db, tc.user, tc.pass)
if !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("Authenticate(%q,%q) error = %v, want ErrInvalidCredentials", tc.user, tc.pass, err)
}
}
}
func TestCreateUserRejectsDuplicateAndBadUsername(t *testing.T) {
db := testDB(t)
ctx := context.Background()
mustCreateUser(t, db, "carol", false)
if _, err := CreateUser(ctx, db, "CAROL", "", "password123", false); !errors.Is(err, ErrUserExists) {
t.Errorf("duplicate error = %v, want ErrUserExists", err)
}
if _, err := CreateUser(ctx, db, "x", "", "password123", false); !errors.Is(err, ErrInvalidUsername) {
t.Errorf("short username error = %v, want ErrInvalidUsername", err)
}
if _, err := CreateUser(ctx, db, "has space", "", "password123", false); !errors.Is(err, ErrInvalidUsername) {
t.Errorf("username with space error = %v, want ErrInvalidUsername", err)
}
}
func TestSetPasswordRevokesSessions(t *testing.T) {
db := testDB(t)
ctx := context.Background()
u := mustCreateUser(t, db, "dave", false)
token, err := CreateSession(ctx, db, u.ID, time.Hour, "test", "127.0.0.1", "test")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if _, err := SessionByToken(ctx, db, token); err != nil {
t.Fatalf("session should exist: %v", err)
}
if err := SetPassword(ctx, db, u.ID, "brandnewpass"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if _, err := SessionByToken(ctx, db, token); !errors.Is(err, ErrNoSession) {
t.Errorf("session survived password change: %v", err)
}
if _, err := Authenticate(ctx, db, "dave", "brandnewpass"); err != nil {
t.Errorf("new password does not work: %v", err)
}
}
// ── sessions ───────────────────────────────────────────────────────────
func TestSessionExpiry(t *testing.T) {
db := testDB(t)
ctx := context.Background()
u := mustCreateUser(t, db, "erin", false)
token, err := CreateSession(ctx, db, u.ID, -time.Minute, "test", "", "")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if _, err := SessionByToken(ctx, db, token); !errors.Is(err, ErrNoSession) {
t.Errorf("expired session resolved: %v", err)
}
}
func TestDeleteExpiredSessions(t *testing.T) {
db := testDB(t)
ctx := context.Background()
u := mustCreateUser(t, db, "frank", false)
if _, err := CreateSession(ctx, db, u.ID, -time.Hour, "", "", ""); err != nil {
t.Fatal(err)
}
if _, err := CreateSession(ctx, db, u.ID, time.Hour, "", "", ""); err != nil {
t.Fatal(err)
}
n, err := DeleteExpiredSessions(ctx, db)
if err != nil {
t.Fatalf("DeleteExpiredSessions: %v", err)
}
if n != 1 {
t.Errorf("deleted %d sessions, want 1", n)
}
}
func TestRawTokenIsNotStored(t *testing.T) {
db := testDB(t)
ctx := context.Background()
u := mustCreateUser(t, db, "grace", false)
token, err := CreateSession(ctx, db, u.ID, time.Hour, "", "", "")
if err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM sessions WHERE token_hash = ?`, token).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("raw session token found in the database")
}
}
// ── grants / visibility ────────────────────────────────────────────────
func TestLibraryVisibility(t *testing.T) {
db := testDB(t)
ctx := context.Background()
comics := mustCreateLibrary(t, db, "Comics", "comics", "/media/comics")
ebooks := mustCreateLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
kid := mustCreateUser(t, db, "kid", false)
admin := mustCreateUser(t, db, "admin", true)
if err := GrantLibrary(ctx, db, kid.ID, comics, true, false); err != nil {
t.Fatalf("GrantLibrary: %v", err)
}
kidLibs, err := VisibleLibraryIDs(ctx, db, kid)
if err != nil {
t.Fatal(err)
}
if len(kidLibs) != 1 || kidLibs[0] != comics {
t.Errorf("kid visible libraries = %v, want [%d]", kidLibs, comics)
}
if ok, err := CanAccessLibrary(ctx, db, kid, comics); err != nil || !ok {
t.Errorf("kid should access comics: ok=%v err=%v", ok, err)
}
if ok, err := CanAccessLibrary(ctx, db, kid, ebooks); err != nil || ok {
t.Errorf("kid should NOT access ebooks: ok=%v err=%v", ok, err)
}
adminLibs, err := VisibleLibraryIDs(ctx, db, admin)
if err != nil {
t.Fatal(err)
}
if len(adminLibs) != 2 {
t.Errorf("admin visible libraries = %v, want both", adminLibs)
}
// Revoking removes access.
if err := RevokeLibrary(ctx, db, kid.ID, comics); err != nil {
t.Fatal(err)
}
if ok, _ := CanAccessLibrary(ctx, db, kid, comics); ok {
t.Error("kid still has access after revocation")
}
}
func TestGrantLibraryUpserts(t *testing.T) {
db := testDB(t)
ctx := context.Background()
u := mustCreateUser(t, db, "henry", false)
lib := mustCreateLibrary(t, db, "Music", "music", "/media/music")
if err := GrantLibrary(ctx, db, u.ID, lib, true, false); err != nil {
t.Fatal(err)
}
if err := GrantLibrary(ctx, db, u.ID, lib, false, true); err != nil {
t.Fatal(err)
}
grants, err := LibraryGrants(ctx, db, u.ID)
if err != nil {
t.Fatal(err)
}
if len(grants) != 1 {
t.Fatalf("grants = %v, want exactly 1", grants)
}
if grants[0].CanRead || !grants[0].CanDownload {
t.Errorf("grant = %+v, want read=false download=true", grants[0])
}
}
// ── HTTP flow ──────────────────────────────────────────────────────────
func newAuthServer(t *testing.T, db *sql.DB) http.Handler {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
a := NewAuthenticator(db, "vellum_session", time.Hour, false, log)
mux := http.NewServeMux()
NewHandlers(a, log).Register(mux)
return a.Middleware(mux)
}
func postJSON(t *testing.T, h http.Handler, path, body string, cookies []*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestHTTPLoginMeLogout(t *testing.T) {
db := testDB(t)
h := newAuthServer(t, db)
mustCreateUser(t, db, "irene", false)
// Unauthenticated /me.
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated /me status = %d, want 401", rec.Code)
}
// Wrong password.
rec = postJSON(t, h, "/api/v1/auth/login", `{"username":"irene","password":"nope"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad login status = %d, want 401", rec.Code)
}
// Correct password.
rec = postJSON(t, h, "/api/v1/auth/login", `{"username":"irene","password":"password123"}`, nil)
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("login did not set a cookie")
}
var sessCookie *http.Cookie
for _, c := range cookies {
if c.Name == "vellum_session" {
sessCookie = c
}
}
if sessCookie == nil {
t.Fatal("no vellum_session cookie set")
}
if !sessCookie.HttpOnly {
t.Error("session cookie is not HttpOnly")
}
// Authenticated /me.
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.AddCookie(sessCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/me status = %d, want 200", rec.Code)
}
var me struct {
User struct {
Username string `json:"username"`
} `json:"user"`
VisibleLibraries []int64 `json:"visible_libraries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatalf("decode /me: %v", err)
}
if me.User.Username != "irene" {
t.Errorf("/me username = %q", me.User.Username)
}
// Logout clears the session server-side.
rec = postJSON(t, h, "/api/v1/auth/logout", "", []*http.Cookie{sessCookie})
if rec.Code != http.StatusOK {
t.Fatalf("logout status = %d", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.AddCookie(sessCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("session still valid after logout: %d", rec.Code)
}
}
func TestBearerTokenAlsoWorks(t *testing.T) {
db := testDB(t)
h := newAuthServer(t, db)
u := mustCreateUser(t, db, "jill", false)
token, err := CreateSession(context.Background(), db, u.ID, time.Hour, "", "", "")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer /me status = %d, want 200", rec.Code)
}
}
func TestAdminRoutesRequireAdmin(t *testing.T) {
db := testDB(t)
h := newAuthServer(t, db)
user := mustCreateUser(t, db, "mallory", false)
token, err := CreateSession(context.Background(), db, user.ID, time.Hour, "", "", "")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("non-admin /users status = %d, want 403", rec.Code)
}
}
func TestAdminCreatesUserAndGrants(t *testing.T) {
db := testDB(t)
h := newAuthServer(t, db)
admin := mustCreateUser(t, db, "root", true)
lib := mustCreateLibrary(t, db, "Games", "games", "/media/games")
token, err := CreateSession(context.Background(), db, admin.ID, time.Hour, "", "", "")
if err != nil {
t.Fatal(err)
}
authHeader := "Bearer " + token
// Create a user.
req := httptest.NewRequest(http.MethodPost, "/api/v1/users",
strings.NewReader(`{"username":"newbie","password":"password123"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", authHeader)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create user status = %d, body=%s", rec.Code, rec.Body.String())
}
// Grant a library to user 2 (newbie is the second user).
req = httptest.NewRequest(http.MethodPut, "/api/v1/users/2/grants/"+itoa(lib),
strings.NewReader(`{"can_read":true,"can_download":true}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", authHeader)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("grant status = %d, body=%s", rec.Code, rec.Body.String())
}
grants, err := LibraryGrants(context.Background(), db, 2)
if err != nil {
t.Fatal(err)
}
if len(grants) != 1 || !grants[0].CanRead || !grants[0].CanDownload {
t.Errorf("grants = %+v", grants)
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }

View file

@ -0,0 +1,41 @@
package auth
import (
"context"
"database/sql"
"log/slog"
)
// EnsureAdminFromEnv creates the first administrator on an empty database.
//
// This runs only when no users exist, so it is idempotent across restarts and
// cannot reset a password or add an admin to an established install. If the
// database is empty and no credentials are configured, it logs a warning
// rather than inventing a default password — a known-weak default on a
// LAN-reachable service is exactly how these servers get compromised.
func EnsureAdminFromEnv(ctx context.Context, db *sql.DB, log *slog.Logger, username, password string) error {
n, err := CountUsers(ctx, db)
if err != nil {
return err
}
if n > 0 {
return nil
}
if username == "" || password == "" {
log.Warn("no user accounts exist and VELLUM_ADMIN_USER / VELLUM_ADMIN_PASSWORD are unset; " +
"the API is reachable but nobody can log in. Set both and restart to create the first admin.")
return nil
}
if len(password) < 8 {
log.Warn("VELLUM_ADMIN_PASSWORD is shorter than 8 characters; refusing to create the admin account")
return nil
}
u, err := CreateUser(ctx, db, username, "Administrator", password, true)
if err != nil {
return err
}
log.Info("created first administrator", "username", u.Username)
return nil
}

371
internal/auth/handlers.go Normal file
View file

@ -0,0 +1,371 @@
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)
}
}
}

171
internal/auth/middleware.go Normal file
View file

@ -0,0 +1,171 @@
package auth
import (
"context"
"database/sql"
"log/slog"
"net"
"net/http"
"time"
"github.com/mithral/vellum/internal/httpx"
)
// contextKey is unexported so no other package can collide with our context values.
type contextKey string
const (
userContextKey contextKey = "auth.user"
sessionContextKey contextKey = "auth.session"
)
// Authenticator loads sessions from cookies and enforces access.
type Authenticator struct {
DB *sql.DB
CookieName string
CookieTTL time.Duration
Secure bool
Log *slog.Logger
}
// NewAuthenticator builds an Authenticator with sane cookie defaults.
func NewAuthenticator(db *sql.DB, cookieName string, ttl time.Duration, secure bool, log *slog.Logger) *Authenticator {
if cookieName == "" {
cookieName = "vellum_session"
}
return &Authenticator{
DB: db,
CookieName: cookieName,
CookieTTL: ttl,
Secure: secure,
Log: log,
}
}
// Middleware attaches the current user to the request context when a valid
// session cookie is present. It never rejects a request: enforcement is the
// job of RequireAuth, so public routes can also see the caller.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if token, err := a.TokenFromRequest(r); err == nil {
if sess, err := SessionByToken(ctx, a.DB, token); err == nil {
if u, err := GetUserByID(ctx, a.DB, sess.UserID); err == nil {
ctx = context.WithValue(ctx, userContextKey, u)
ctx = context.WithValue(ctx, sessionContextKey, sess)
// Best-effort; must not fail the request.
if err := TouchSession(ctx, a.DB, sess.ID); err != nil {
a.Log.Warn("touch session", "err", err)
}
r = r.WithContext(ctx)
} else if a.Log != nil {
a.Log.Warn("session references missing user", "user_id", sess.UserID)
}
} else if err != ErrNoSession && a.Log != nil {
a.Log.Warn("load session", "err", err)
}
}
next.ServeHTTP(w, r)
})
}
// RequireAuth rejects unauthenticated requests with 401.
func (a *Authenticator) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := CurrentUser(r.Context()); !ok {
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(w, r)
})
}
// RequireAdmin rejects authenticated non-admins with 403.
func (a *Authenticator) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := CurrentUser(r.Context())
if !ok {
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
return
}
if !u.IsAdmin {
httpx.WriteError(w, http.StatusForbidden, "administrator access required")
return
}
next.ServeHTTP(w, r)
})
}
// Require calls fn with the current user, or 401s if there is none.
func (a *Authenticator) Require(fn func(http.ResponseWriter, *http.Request, User)) http.Handler {
return a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
fn(w, r, u)
}))
}
// CurrentUser returns the authenticated user, if any.
func CurrentUser(ctx context.Context) (User, bool) {
u, ok := ctx.Value(userContextKey).(User)
return u, ok
}
// CurrentSession returns the session backing the authenticated user, if any.
func CurrentSession(ctx context.Context) (Session, bool) {
s, ok := ctx.Value(sessionContextKey).(Session)
return s, ok
}
// ── cookies ────────────────────────────────────────────────────────────
// SetSessionCookie issues the session cookie. HttpOnly and SameSite=Lax are
// always on; Secure follows configuration (it must be off for plain-HTTP dev).
func (a *Authenticator) SetSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: a.CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: a.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie removes the session cookie.
func (a *Authenticator) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: a.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: a.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// TokenFromRequest reads the session token from the cookie, or the
// Authorization: Bearer header for non-browser clients (native apps, CLIs).
func (a *Authenticator) TokenFromRequest(r *http.Request) (string, error) {
if c, err := r.Cookie(a.CookieName); err == nil && c.Value != "" {
return c.Value, nil
}
if h := r.Header.Get("Authorization"); len(h) > 7 && h[:7] == "Bearer " {
return h[7:], nil
}
return "", ErrNoSession
}
// ClientIP extracts a best-effort client address for logging and rate limiting.
// It deliberately does NOT trust X-Forwarded-For: behind an untrusted network
// that header is attacker-controlled, and this server is LAN-facing.
func ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

106
internal/auth/password.go Normal file
View file

@ -0,0 +1,106 @@
// Package auth implements identity: password hashing, sessions, per-library
// grants, and the middleware that turns a cookie into a current user.
//
// Nothing outside this package may inspect a password or a raw session token.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. These are the OWASP-recommended baseline for
// interactive logins (memory cost is the dominant factor). They are encoded
// into every hash, so changing them here does not invalidate old passwords —
// existing hashes keep verifying with their own stored parameters.
const (
argonTime = 2
argonMemory = 64 * 1024 // KiB, i.e. 64 MiB
argonThreads = 4
argonKeyLen = 32
argonSaltLen = 16
)
var (
// ErrInvalidHash means a stored password hash is not in the PHC format
// this package writes. It indicates database corruption, not a bad login.
ErrInvalidHash = errors.New("auth: password hash is not in PHC format")
// ErrIncompatibleVersion means the hash was made by a newer argon2 version.
ErrIncompatibleVersion = errors.New("auth: incompatible argon2 version")
)
// HashPassword derives an argon2id hash and returns it as a PHC string:
//
// $argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>
//
// The parameters travel with the hash, so verification never needs them passed
// in and parameter changes are backward compatible.
func HashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("auth: read salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// VerifyPassword reports whether password matches the PHC-encoded hash.
// It always performs the full derivation, so a malformed hash and a wrong
// password cost the same — there is no timing oracle for "user exists".
func VerifyPassword(password, encoded string) (bool, error) {
salt, want, params, err := decodeHash(encoded)
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
type argonParams struct {
time uint32
memory uint32
threads uint8
}
func decodeHash(encoded string) (salt, hash []byte, p argonParams, err error) {
parts := strings.Split(encoded, "$")
// Leading empty field from the leading '$' gives 6 fields.
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
return nil, nil, p, ErrInvalidHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return nil, nil, p, ErrInvalidHash
}
if version != argon2.Version {
return nil, nil, p, ErrIncompatibleVersion
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.threads); err != nil {
return nil, nil, p, ErrInvalidHash
}
salt, err = base64.RawStdEncoding.Strict().DecodeString(parts[4])
if err != nil {
return nil, nil, p, ErrInvalidHash
}
hash, err = base64.RawStdEncoding.Strict().DecodeString(parts[5])
if err != nil {
return nil, nil, p, ErrInvalidHash
}
if len(hash) == 0 || len(salt) == 0 {
return nil, nil, p, ErrInvalidHash
}
return salt, hash, p, nil
}

View file

@ -0,0 +1,75 @@
package auth
import (
"errors"
"strings"
"testing"
)
func TestHashAndVerify(t *testing.T) {
const pw = "correct horse battery staple"
encoded, err := HashPassword(pw)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !strings.HasPrefix(encoded, "$argon2id$") {
t.Errorf("hash %q is not an argon2id PHC string", encoded)
}
ok, err := VerifyPassword(pw, encoded)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Error("correct password did not verify")
}
ok, err = VerifyPassword("wrong password", encoded)
if err != nil {
t.Fatalf("VerifyPassword(wrong): %v", err)
}
if ok {
t.Error("wrong password verified")
}
}
func TestHashIsSalted(t *testing.T) {
a, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
b, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
if a == b {
t.Error("two hashes of the same password are identical; salt is not random")
}
}
func TestVerifyRejectsMalformedHash(t *testing.T) {
for _, bad := range []string{
"",
"not-a-hash",
"$argon2id$v=19$m=65536,t=2,p=4$onlyonefield",
"$bcrypt$v=19$m=65536,t=2,p=4$c2FsdA$aGFzaA",
} {
if _, err := VerifyPassword("x", bad); err == nil {
t.Errorf("VerifyPassword(%q) succeeded, want error", bad)
} else if !errors.Is(err, ErrInvalidHash) {
t.Errorf("VerifyPassword(%q) error = %v, want ErrInvalidHash", bad, err)
}
}
}
func TestVerifyRejectsFutureArgonVersion(t *testing.T) {
h, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
bumped := strings.Replace(h, "v=19", "v=20", 1)
if _, err := VerifyPassword("x", bumped); !errors.Is(err, ErrIncompatibleVersion) {
t.Errorf("error = %v, want ErrIncompatibleVersion", err)
}
}

138
internal/auth/session.go Normal file
View file

@ -0,0 +1,138 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"time"
)
// ErrNoSession means no session exists for the presented token, or it expired.
var ErrNoSession = errors.New("auth: no such session")
// Session is a logged-in browser or device.
//
// The token itself is NEVER stored. Only its SHA-256 is, so a database leak
// does not hand out live sessions.
type Session struct {
ID string
UserID int64
DeviceLabel string
IP string
UserAgent string
CreatedAt time.Time
LastSeen time.Time
ExpiresAt time.Time
}
// newSessionToken returns a fresh opaque token and its storage hash.
//
// The token is 32 bytes of CSPRNG output, base64url-encoded. Hashing with
// SHA-256 (not argon2) is correct here: the token already has full entropy, so
// there is nothing to brute-force and lookups must stay cheap.
func newSessionToken() (token string, hash string, err error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", fmt.Errorf("auth: read session token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(raw)
return token, hashToken(token), nil
}
// hashToken is the deterministic mapping from token to stored value.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// CreateSession stores a new session for userID and returns the raw token.
// The raw token is returned exactly once and never persisted.
func CreateSession(ctx context.Context, db *sql.DB, userID int64, ttl time.Duration, deviceLabel, ip, userAgent string) (string, error) {
token, hash, err := newSessionToken()
if err != nil {
return "", err
}
now := time.Now().UTC()
_, err = db.ExecContext(ctx, `
INSERT INTO sessions (id, user_id, token_hash, device_label, ip, user_agent, created_at, last_seen, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
hash[:16], userID, hash, deviceLabel, ip, userAgent,
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
now.Add(ttl).Format(time.RFC3339Nano),
)
if err != nil {
return "", fmt.Errorf("auth: create session: %w", err)
}
return token, nil
}
// SessionByToken resolves a raw token to its session, or ErrNoSession if the
// token is unknown or expired.
func SessionByToken(ctx context.Context, db *sql.DB, token string) (Session, error) {
var (
s Session
created string
lastSeen string
expiresAt string
)
err := db.QueryRowContext(ctx, `
SELECT id, user_id, device_label, ip, user_agent, created_at, last_seen, expires_at
FROM sessions WHERE token_hash = ?`, hashToken(token),
).Scan(&s.ID, &s.UserID, &s.DeviceLabel, &s.IP, &s.UserAgent, &created, &lastSeen, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrNoSession
}
if err != nil {
return Session{}, fmt.Errorf("auth: load session: %w", err)
}
if s.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
return Session{}, fmt.Errorf("auth: parse created_at: %w", err)
}
if s.LastSeen, err = time.Parse(time.RFC3339Nano, lastSeen); err != nil {
return Session{}, fmt.Errorf("auth: parse last_seen: %w", err)
}
if s.ExpiresAt, err = time.Parse(time.RFC3339Nano, expiresAt); err != nil {
return Session{}, fmt.Errorf("auth: parse expires_at: %w", err)
}
if time.Now().UTC().After(s.ExpiresAt) {
return Session{}, ErrNoSession
}
return s, nil
}
// TouchSession updates last_seen. Best-effort: a failure here must never fail
// the request that triggered it, so callers log rather than propagate.
func TouchSession(ctx context.Context, db *sql.DB, sessionID string) error {
_, err := db.ExecContext(ctx,
`UPDATE sessions SET last_seen = ? WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339Nano), sessionID)
return err
}
// DeleteSession logs out a single session by its raw token.
func DeleteSession(ctx context.Context, db *sql.DB, token string) error {
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token))
return err
}
// DeleteUserSessions logs a user out everywhere.
func DeleteUserSessions(ctx context.Context, db *sql.DB, userID int64) error {
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
// DeleteExpiredSessions is housekeeping, run periodically.
func DeleteExpiredSessions(ctx context.Context, db *sql.DB) (int64, error) {
res, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`,
time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
return res.RowsAffected()
}

326
internal/auth/store.go Normal file
View file

@ -0,0 +1,326 @@
package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
var (
// ErrUserExists means the username is taken (case-insensitive).
ErrUserExists = errors.New("auth: username already taken")
// ErrUserNotFound means no user matched.
ErrUserNotFound = errors.New("auth: user not found")
// ErrInvalidUsername means the username failed validation.
ErrInvalidUsername = errors.New("auth: invalid username")
// ErrInvalidCredentials is returned for both unknown users and bad
// passwords, so callers cannot distinguish the two.
ErrInvalidCredentials = errors.New("auth: invalid credentials")
)
// usernamePattern keeps usernames URL- and log-safe, and avoids the
// confusable-character mess of allowing arbitrary Unicode identifiers.
var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{2,31}$`)
// User is an account. The password hash is never serialized to a client.
type User struct {
ID int64
Username string
DisplayName string
PasswordHash string
IsAdmin bool
CreatedAt time.Time
}
// ValidateUsername enforces the username policy.
func ValidateUsername(username string) error {
if !usernamePattern.MatchString(username) {
return fmt.Errorf("%w: must be 3-32 characters, start with a letter or digit, and contain only letters, digits, dot, underscore or hyphen", ErrInvalidUsername)
}
return nil
}
// CreateUser hashes the password and inserts the account.
func CreateUser(ctx context.Context, db *sql.DB, username, displayName, password string, isAdmin bool) (User, error) {
if err := ValidateUsername(username); err != nil {
return User{}, err
}
if password == "" {
return User{}, errors.New("auth: password must not be empty")
}
if displayName == "" {
displayName = username
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
u := User{Username: username, DisplayName: displayName, IsAdmin: isAdmin}
var created string
err = db.QueryRowContext(ctx, `
INSERT INTO users (username, display_name, password_hash, is_admin)
VALUES (?, ?, ?, ?)
RETURNING id, created_at`,
username, displayName, hash, boolToInt(isAdmin),
).Scan(&u.ID, &created)
if err != nil {
if isUniqueViolation(err) {
return User{}, ErrUserExists
}
return User{}, fmt.Errorf("auth: create user: %w", err)
}
u.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
u.PasswordHash = hash
return u, nil
}
// GetUserByUsername does a case-insensitive lookup (the column is COLLATE NOCASE).
func GetUserByUsername(ctx context.Context, db *sql.DB, username string) (User, error) {
return scanUser(db.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username))
}
// GetUserByID loads one user by primary key.
func GetUserByID(ctx context.Context, db *sql.DB, id int64) (User, error) {
return scanUser(db.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id))
}
// ListUsers returns every account, oldest first.
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
rows, err := db.QueryContext(ctx, userSelect+` ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("auth: list users: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUserRow(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// CountUsers reports how many accounts exist. Used to decide whether to
// bootstrap the first admin.
func CountUsers(ctx context.Context, db *sql.DB) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n)
return n, err
}
// SetPassword replaces a user's password and logs out all their sessions.
// Logging out everywhere is deliberate: a password change is often a response
// to a suspected compromise.
func SetPassword(ctx context.Context, db *sql.DB, userID int64, password string) error {
if password == "" {
return errors.New("auth: password must not be empty")
}
hash, err := HashPassword(password)
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, userID); err != nil {
return fmt.Errorf("auth: set password: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return fmt.Errorf("auth: clear sessions: %w", err)
}
return tx.Commit()
}
// ── Authentication ─────────────────────────────────────────────────────
// Authenticate verifies a username/password pair and returns the user.
//
// It hashes even when the user does not exist, so response time does not
// reveal whether a username is registered.
func Authenticate(ctx context.Context, db *sql.DB, username, password string) (User, error) {
u, err := GetUserByUsername(ctx, db, username)
if errors.Is(err, sql.ErrNoRows) {
_, _ = HashPassword(password) // burn equivalent time
return User{}, ErrInvalidCredentials
}
if err != nil {
return User{}, err
}
ok, err := VerifyPassword(password, u.PasswordHash)
if err != nil {
// A malformed stored hash is an operational error, not a bad login.
return User{}, fmt.Errorf("auth: verify password for %q: %w", u.Username, err)
}
if !ok {
return User{}, ErrInvalidCredentials
}
return u, nil
}
// ── Library grants ─────────────────────────────────────────────────────
// Grant is one user's access to one library.
type Grant struct {
LibraryID int64
CanRead bool
CanDownload bool
}
// GrantLibrary upserts a user's access to a library.
func GrantLibrary(ctx context.Context, db *sql.DB, userID, libraryID int64, canRead, canDownload bool) error {
_, err := db.ExecContext(ctx, `
INSERT INTO library_grants (user_id, library_id, can_read, can_download)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, library_id)
DO UPDATE SET can_read = excluded.can_read, can_download = excluded.can_download`,
userID, libraryID, boolToInt(canRead), boolToInt(canDownload))
if err != nil {
return fmt.Errorf("auth: grant library: %w", err)
}
return nil
}
// RevokeLibrary removes a user's access to a library.
func RevokeLibrary(ctx context.Context, db *sql.DB, userID, libraryID int64) error {
_, err := db.ExecContext(ctx, `DELETE FROM library_grants WHERE user_id = ? AND library_id = ?`, userID, libraryID)
if err != nil {
return fmt.Errorf("auth: revoke library: %w", err)
}
return nil
}
// LibraryGrants lists a user's explicit grants.
func LibraryGrants(ctx context.Context, db *sql.DB, userID int64) ([]Grant, error) {
rows, err := db.QueryContext(ctx,
`SELECT library_id, can_read, can_download FROM library_grants WHERE user_id = ?`, userID)
if err != nil {
return nil, fmt.Errorf("auth: list grants: %w", err)
}
defer rows.Close()
var out []Grant
for rows.Next() {
var g Grant
var read, download int
if err := rows.Scan(&g.LibraryID, &read, &download); err != nil {
return nil, err
}
g.CanRead, g.CanDownload = read == 1, download == 1
out = append(out, g)
}
return out, rows.Err()
}
// VisibleLibraryIDs returns the libraries a user may read.
//
// This is the single access-control chokepoint. Admins see every library;
// everyone else sees exactly their grants with can_read set. Every endpoint
// that returns media must filter through this or CanAccessLibrary.
func VisibleLibraryIDs(ctx context.Context, db *sql.DB, u User) ([]int64, error) {
if u.IsAdmin {
rows, err := db.QueryContext(ctx, `SELECT id FROM libraries ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("auth: list all libraries: %w", err)
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
rows, err := db.QueryContext(ctx,
`SELECT library_id FROM library_grants WHERE user_id = ? AND can_read = 1 ORDER BY library_id`, u.ID)
if err != nil {
return nil, fmt.Errorf("auth: list visible libraries: %w", err)
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// CanAccessLibrary reports whether a user may read one library.
func CanAccessLibrary(ctx context.Context, db *sql.DB, u User, libraryID int64) (bool, error) {
if u.IsAdmin {
return true, nil
}
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM library_grants WHERE user_id = ? AND library_id = ? AND can_read = 1`,
u.ID, libraryID).Scan(&n)
if err != nil {
return false, fmt.Errorf("auth: check library access: %w", err)
}
return n > 0, nil
}
// ── helpers ────────────────────────────────────────────────────────────
const userSelect = `SELECT id, username, display_name, password_hash, is_admin, created_at FROM users`
type rowScanner interface {
Scan(dest ...any) error
}
func scanUser(row rowScanner) (User, error) {
u, err := scanUserRow(row)
if errors.Is(err, sql.ErrNoRows) {
return User{}, sql.ErrNoRows
}
return u, err
}
func scanUserRow(row rowScanner) (User, error) {
var u User
var isAdmin int
var created string
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &isAdmin, &created); err != nil {
return User{}, err
}
u.IsAdmin = isAdmin == 1
u.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
return u, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// isUniqueViolation sniffs the SQLite error for a uniqueness constraint.
// modernc.org/sqlite does not export a typed error for this, so string
// matching on the driver message is the pragmatic option.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "UNIQUE CONSTRAINT FAILED") || strings.Contains(msg, "CONSTRAINT FAILED: UNIQUE")
}

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -26,6 +27,14 @@ type Config struct {
LogLevel string LogLevel string
LogFormat string LogFormat string
// AdminUser / AdminPassword create the first administrator, but only when
// the users table is empty. Ignored on any established database.
AdminUser string
AdminPassword string
CookieName string
CookieSecure bool
SessionTTL time.Duration SessionTTL time.Duration
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout time.Duration WriteTimeout time.Duration
@ -43,6 +52,13 @@ func LoadConfig() (Config, error) {
MediaDirs: envList("VELLUM_MEDIA_DIRS", nil), MediaDirs: envList("VELLUM_MEDIA_DIRS", nil),
LogLevel: env("VELLUM_LOG_LEVEL", "info"), LogLevel: env("VELLUM_LOG_LEVEL", "info"),
LogFormat: env("VELLUM_LOG_FORMAT", "text"), // text|json LogFormat: env("VELLUM_LOG_FORMAT", "text"), // text|json
AdminUser: env("VELLUM_ADMIN_USER", ""),
AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""),
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
// Secure defaults to false so plain-HTTP development works. Production
// behind TLS must set VELLUM_COOKIE_SECURE=true; boot logs a warning
// when it is off.
CookieSecure: envBool("VELLUM_COOKIE_SECURE", false),
SessionTTL: envDuration("VELLUM_SESSION_TTL", 30*24*time.Hour), SessionTTL: envDuration("VELLUM_SESSION_TTL", 30*24*time.Hour),
ReadTimeout: envDuration("VELLUM_READ_TIMEOUT", 15*time.Second), ReadTimeout: envDuration("VELLUM_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this
@ -118,3 +134,15 @@ func envDuration(key string, def time.Duration) time.Duration {
} }
return d return d
} }
func envBool(key string, def bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}

View file

@ -14,6 +14,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
"github.com/mithral/vellum/internal/auth"
) )
// Server holds the dependencies every handler needs. // Server holds the dependencies every handler needs.
@ -23,6 +25,7 @@ type Server struct {
version string version string
started time.Time started time.Time
mux *http.ServeMux mux *http.ServeMux
auth *auth.Authenticator
} }
// New builds a Server and registers the core routes. // New builds a Server and registers the core routes.
@ -38,6 +41,13 @@ func New(db *sql.DB, log *slog.Logger, version string) *Server {
return s return s
} }
// Mux exposes the root mux so feature modules can mount their own routes.
func (s *Server) Mux() *http.ServeMux { return s.mux }
// SetAuth installs the session middleware. Until it is set, no request carries
// a current user.
func (s *Server) SetAuth(a *auth.Authenticator) { s.auth = a }
// Handle lets a module mount routes on the shared mux. // Handle lets a module mount routes on the shared mux.
func (s *Server) Handle(pattern string, h http.Handler) { func (s *Server) Handle(pattern string, h http.Handler) {
s.mux.Handle(pattern, h) s.mux.Handle(pattern, h)
@ -52,9 +62,14 @@ func (s *Server) routes() {
// Handler returns the fully wrapped root handler. // Handler returns the fully wrapped root handler.
// //
// Order matters: the request ID must be attached before the logging // Order matters: the request ID must be attached before the logging
// middleware runs, so the log line can include it. // middleware runs, so the log line can include it. Session loading sits
// innermost so it runs after logging has started timing.
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
return s.withRecovery(s.withRequestID(s.withRequestLogging(s.mux))) var h http.Handler = s.mux
if s.auth != nil {
h = s.auth.Middleware(h)
}
return s.withRecovery(s.withRequestID(s.withRequestLogging(h)))
} }
// ── Middleware ───────────────────────────────────────────────────────── // ── Middleware ─────────────────────────────────────────────────────────

90
internal/httpx/json.go Normal file
View file

@ -0,0 +1,90 @@
// Package httpx holds small HTTP helpers shared by feature packages without
// creating an import cycle (feature modules import httpx; httpx imports
// nothing of ours).
package httpx
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)
// maxRequestBody caps decoded JSON bodies to protect against memory abuse.
const maxRequestBody = 1 << 20 // 1 MiB
// WriteJSON encodes v as JSON with the given status.
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if v == nil {
return
}
_ = json.NewEncoder(w).Encode(v)
}
// WriteError writes a JSON error envelope.
func WriteError(w http.ResponseWriter, status int, message string) {
WriteJSON(w, status, map[string]string{"error": message})
}
// DecodeJSON reads a JSON request body into dst.
//
// It rejects unknown fields and non-object top-level values, and refuses
// bodies larger than 1 MiB. Error messages are safe to return to clients.
func DecodeJSON(r *http.Request, dst any) error {
if ct := r.Header.Get("Content-Type"); ct != "" {
if mediaType := contentTypeOnly(ct); mediaType != "application/json" {
return fmt.Errorf("expected Content-Type application/json, got %q", ct)
}
}
dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBody))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxErr):
return fmt.Errorf("malformed JSON at byte %d", syntaxErr.Offset)
case errors.As(err, &typeErr):
return fmt.Errorf("field %q has the wrong type", typeErr.Field)
case errors.Is(err, io.EOF):
return errors.New("request body is empty")
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("request body is truncated")
default:
return err
}
}
// Reject trailing garbage after the first JSON value.
if dec.More() {
return errors.New("request body contains more than one JSON value")
}
return nil
}
// contentTypeOnly strips parameters from a Content-Type header, e.g.
// "application/json; charset=utf-8" -> "application/json".
func contentTypeOnly(ct string) string {
for i := 0; i < len(ct); i++ {
if ct[i] == ';' {
return trimSpace(ct[:i])
}
}
return trimSpace(ct)
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}