From 9772e55c73ff7ab7c7658ea033c53b0f88953099 Mon Sep 17 00:00:00 2001 From: mithral Date: Fri, 9 Oct 2026 16:31:24 -0400 Subject: [PATCH] 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. --- cmd/vellum/main.go | 15 ++ go.mod | 7 +- go.sum | 6 +- internal/auth/auth_test.go | 454 +++++++++++++++++++++++++++++++++ internal/auth/bootstrap.go | 41 +++ internal/auth/handlers.go | 371 +++++++++++++++++++++++++++ internal/auth/middleware.go | 171 +++++++++++++ internal/auth/password.go | 106 ++++++++ internal/auth/password_test.go | 75 ++++++ internal/auth/session.go | 138 ++++++++++ internal/auth/store.go | 326 +++++++++++++++++++++++ internal/core/config.go | 28 ++ internal/httpapi/server.go | 19 +- internal/httpx/json.go | 90 +++++++ 14 files changed, 1841 insertions(+), 6 deletions(-) create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/bootstrap.go create mode 100644 internal/auth/handlers.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/session.go create mode 100644 internal/auth/store.go create mode 100644 internal/httpx/json.go diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 1e19a71..62a5834 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/migrations" @@ -53,6 +54,20 @@ func run() error { } 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{ Addr: cfg.Listen, Handler: api.Handler(), diff --git a/go.mod b/go.mod index 42a965c..651d88c 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/mithral/vellum 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 ( 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/ncruces/go-strftime v1.0.0 // 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/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/go.sum b/go.sum index 8b50aa6..e637804 100644 --- a/go.sum +++ b/go.sum @@ -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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= 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.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +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/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..c7a0177 --- /dev/null +++ b/internal/auth/auth_test.go @@ -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) } diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go new file mode 100644 index 0000000..e1e78a0 --- /dev/null +++ b/internal/auth/bootstrap.go @@ -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 +} diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go new file mode 100644 index 0000000..1127024 --- /dev/null +++ b/internal/auth/handlers.go @@ -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) + } + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..5858907 --- /dev/null +++ b/internal/auth/middleware.go @@ -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 +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..cc46cf7 --- /dev/null +++ b/internal/auth/password.go @@ -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$$ +// +// 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 +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..b0f6a3f --- /dev/null +++ b/internal/auth/password_test.go @@ -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) + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..3720f09 --- /dev/null +++ b/internal/auth/session.go @@ -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() +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..2ba938c --- /dev/null +++ b/internal/auth/store.go @@ -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") +} diff --git a/internal/core/config.go b/internal/core/config.go index f345374..fbb2811 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" ) @@ -26,6 +27,14 @@ type Config struct { LogLevel 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 ReadTimeout time.Duration WriteTimeout time.Duration @@ -43,6 +52,13 @@ func LoadConfig() (Config, error) { MediaDirs: envList("VELLUM_MEDIA_DIRS", nil), LogLevel: env("VELLUM_LOG_LEVEL", "info"), 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), ReadTimeout: envDuration("VELLUM_READ_TIMEOUT", 15*time.Second), 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 } + +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 +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 8e64a3d..a9dff54 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -14,6 +14,8 @@ import ( "log/slog" "net/http" "time" + + "github.com/mithral/vellum/internal/auth" ) // Server holds the dependencies every handler needs. @@ -23,6 +25,7 @@ type Server struct { version string started time.Time mux *http.ServeMux + auth *auth.Authenticator } // 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 } +// 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. func (s *Server) Handle(pattern string, h http.Handler) { s.mux.Handle(pattern, h) @@ -52,9 +62,14 @@ func (s *Server) routes() { // Handler returns the fully wrapped root handler. // // 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 { - 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 ───────────────────────────────────────────────────────── diff --git a/internal/httpx/json.go b/internal/httpx/json.go new file mode 100644 index 0000000..51aa485 --- /dev/null +++ b/internal/httpx/json.go @@ -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] +}