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:
parent
eb1ad6d920
commit
9772e55c73
14 changed files with 1841 additions and 6 deletions
|
|
@ -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
7
go.mod
|
|
@ -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
6
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/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
454
internal/auth/auth_test.go
Normal 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) }
|
||||||
41
internal/auth/bootstrap.go
Normal file
41
internal/auth/bootstrap.go
Normal 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
371
internal/auth/handlers.go
Normal 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
171
internal/auth/middleware.go
Normal 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
106
internal/auth/password.go
Normal 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
|
||||||
|
}
|
||||||
75
internal/auth/password_test.go
Normal file
75
internal/auth/password_test.go
Normal 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
138
internal/auth/session.go
Normal 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
326
internal/auth/store.go
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
90
internal/httpx/json.go
Normal 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]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue