Vellum/internal/auth/auth_test.go
mithral 6e04e957b8 Rate limit every surface that checks a password
The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName
and Subsonic's authenticate both called auth.Authenticate with no limiting,
so an attacker brute-forced through those instead: the same password, the
same argon2, no ceiling.

The limiter moves from auth.Handlers onto *Authenticator, which all three
surfaces already hold, so they share one budget and splitting guesses
across surfaces gains nothing. main.go builds one authenticator and hands
it to both compat layers, so the sharing is real rather than intended.

Subsonic needs care: it has no login step, authenticate runs on every
request, so a naive check would charge playback against the login budget
and lock a listener out mid-album. The limiter is consulted only when a
credential was actually supplied, and a success or an uncredentialed
request spends nothing.

Verified red-green on all three: removing the Jellyfin gate fails
TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the
Subsonic credential guard fails
TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or
ResetLogin fails the auth tests.

Known gap, unchanged: the limiter is in-process, so it resets on restart
and does not span replicas. Fine for one binary against one SQLite file.
2026-10-12 00:57:56 -04:00

583 lines
18 KiB
Go

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)
}
}
// sessionTouchCounter installs a trigger that counts every UPDATE to
// sessions.last_seen and returns a reader for the running total. Counting in
// SQLite rather than by diffing timestamps is exact even when two touches land
// in the same millisecond, which is common in a tight request loop.
func sessionTouchCounter(t *testing.T, db *sql.DB) func() int {
t.Helper()
ctx := context.Background()
if _, err := db.ExecContext(ctx, `CREATE TABLE session_touch_count (n INTEGER NOT NULL)`); err != nil {
t.Fatalf("create touch counter: %v", err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO session_touch_count (n) VALUES (0)`); err != nil {
t.Fatalf("seed touch counter: %v", err)
}
if _, err := db.ExecContext(ctx,
`CREATE TRIGGER count_session_touch AFTER UPDATE OF last_seen ON sessions
BEGIN UPDATE session_touch_count SET n = n + 1; END`); err != nil {
t.Fatalf("create touch trigger: %v", err)
}
return func() int {
var n int
if err := db.QueryRowContext(ctx, `SELECT n FROM session_touch_count`).Scan(&n); err != nil {
t.Fatalf("read touch counter: %v", err)
}
return n
}
}
func TestMiddlewareThrottlesSessionTouch(t *testing.T) {
db := testDB(t)
ctx := context.Background()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
a := NewAuthenticator(db, "vellum_session", time.Hour, false, log)
u := mustCreateUser(t, db, "kyle", false)
token, err := CreateSession(ctx, db, u.ID, time.Hour, "", "", "")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
sess, err := SessionByToken(ctx, db, token)
if err != nil {
t.Fatalf("SessionByToken: %v", err)
}
touches := sessionTouchCounter(t, db)
// A trivial terminal handler: the point is only that the request passes
// through Middleware, which is where the touch decision lives.
h := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
burst := func() {
t.Helper()
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/media/track", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("burst request %d status = %d, want 200", i, rec.Code)
}
}
}
// A freshly created session has a recent last_seen, so a 10-request burst
// must not take the write lock at all.
burst()
if got := touches(); got != 0 {
t.Errorf("recent last_seen: %d touches after a 10-request burst, want 0", got)
}
// Backdate last_seen past touchEvery, then reset the counter. The first
// request of the next burst sees a stale value and must touch; every later
// request then sees a fresh one and must be throttled — so the whole burst
// writes exactly once, not ten times.
staleTime := time.Now().Add(-10 * time.Minute).UTC()
if _, err := db.ExecContext(ctx, `UPDATE sessions SET last_seen = ? WHERE id = ?`,
core.Stamp(staleTime), sess.ID); err != nil {
t.Fatalf("backdate last_seen: %v", err)
}
if _, err := db.ExecContext(ctx, `UPDATE session_touch_count SET n = 0`); err != nil {
t.Fatalf("reset touch counter: %v", err)
}
burst()
if got := touches(); got != 1 {
t.Errorf("stale last_seen: %d touches after a 10-request burst, want exactly 1", got)
}
// The single touch must have advanced last_seen past the stale value.
after, err := SessionByToken(ctx, db, token)
if err != nil {
t.Fatalf("SessionByToken after burst: %v", err)
}
if !after.LastSeen.After(staleTime) {
t.Errorf("last_seen = %v, want after %v", after.LastSeen, staleTime)
}
}
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) }
// ── login rate limiting ─────────────────────────────────────────────────
// AllowLogin/ResetLogin are what every password-checking surface calls, so the
// limiter can sit on the Authenticator and one budget covers all of them.
func TestAllowLoginRefusesAfterTenAndResetClears(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
a := NewAuthenticator(nil, "", time.Hour, false, log)
for i := 1; i <= 10; i++ {
if !a.AllowLogin("10.0.0.1", "alice") {
t.Fatalf("attempt %d refused, want it under the limit", i)
}
}
if a.AllowLogin("10.0.0.1", "alice") {
t.Error("11th attempt was allowed")
}
// The window is per ip and username, so nobody else is locked out.
if !a.AllowLogin("10.0.0.2", "alice") {
t.Error("another ip was refused")
}
if !a.AllowLogin("10.0.0.1", "bob") {
t.Error("another username was refused")
}
a.ResetLogin("10.0.0.1", "alice")
if !a.AllowLogin("10.0.0.1", "alice") {
t.Error("attempt refused after a successful login cleared the window")
}
}