From 2f3fbccab6859ba01bde6f9b2613236dfccb8ab6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 09:35:52 -0400 Subject: [PATCH] feat(auth): first account on a new server needs the setup token from the server log (M462 #4982) While no accounts exist, the server mints a random setup token at boot and logs it. Registering the first account (which becomes admin) must carry it, so whoever reaches a freshly exposed instance first cannot claim it. The register page asks GET /api/auth/setup-status and shows a "Setup token" field in place of the invite field while setup is pending. Co-Authored-By: Claude Opus 5.5 --- internal/api/api.go | 16 ++++++ internal/api/auth_register.go | 53 ++++++++++++++++- internal/api/auth_register_test.go | 72 ++++++++++++++++++++++++ internal/auth/setup.go | 46 +++++++++++++++ web/src/lib/auth/store.svelte.ts | 10 ++++ web/src/routes/register/+page.svelte | 59 +++++++++++++++---- web/src/routes/register/register.test.ts | 36 +++++++++++- 7 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 internal/auth/setup.go diff --git a/internal/api/api.go b/internal/api/api.go index f1519c9e..66d8ab87 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -36,6 +36,13 @@ import ( // is shared with the Subsonic mount so /rest/scrobble feeds the same store. func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) { rng := rand.New(rand.NewSource(rand.Int63())) + setupToken, err := auth.NewSetupToken() + if err != nil { + // crypto/rand failing means the platform can't make secrets at all; + // sessions would be minted from the same source. Nothing to degrade to. + panic("api: mint setup token: " + err.Error()) + } + logSetupTokenIfNeeded(pool, logger, setupToken) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, recSettings: recSettings, @@ -60,6 +67,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev fingerprintSettings: fpSettings, librarySize: recommendation.NewLibrarySize(nil), loginGuard: auth.NewLoginGuard(), + setupToken: setupToken, + requireSetupToken: true, registerLimit: auth.NewAttemptLimiter(registerPerAddressMax, time.Hour), forgotAddressLimit: auth.NewAttemptLimiter(forgotPerAddressMax, time.Hour), forgotEmailLimit: auth.NewAttemptLimiter(forgotPerEmailMax, time.Hour), @@ -69,6 +78,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) api.Post("/auth/register", h.handleRegister) + api.Get("/auth/setup-status", h.handleSetupStatus) api.Post("/auth/forgot-password", h.handleForgotPassword) api.Post("/auth/reset-password", h.handleResetPassword) @@ -319,6 +329,12 @@ type handlers struct { // instance the scanner and the fingerprint workers read, so a save from the // admin card reaches them without a restart. Nil serves the defaults. fingerprintSettings *library.FingerprintSettingsService + // setupToken must accompany the first registration while no users exist + // (see auth.SetupToken). requireSetupToken is set by Mount, the only + // production constructor; tests that build handlers directly leave it + // off unless they are testing it. + setupToken *auth.SetupToken + requireSetupToken bool // loginGuard throttles failed logins per account and per address, and // the limiters below cap the other unauthenticated auth routes. All are // nil-safe, so tests that build handlers directly run unthrottled. diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index 4ea5cddd..1c14ad64 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -1,8 +1,10 @@ package api import ( + "context" "encoding/json" "errors" + "log/slog" "net/http" "regexp" "time" @@ -10,6 +12,7 @@ import ( "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" @@ -26,9 +29,12 @@ var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`) const minPasswordLength = 8 type registerReq struct { - Username string `json:"username"` - Password string `json:"password"` - InviteToken string `json:"invite_token"` + Username string `json:"username"` + Password string `json:"password"` + InviteToken string `json:"invite_token"` + // SetupToken is required only for the very first account; see + // auth.SetupToken. + SetupToken string `json:"setup_token"` DisplayName *string `json:"display_name"` } @@ -88,6 +94,19 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { return } + // The first account becomes admin, so it must prove it can read the + // server log. Checked before the invite logic, which the empty-users + // state skips. + if userCount == 0 && h.requireSetupToken && !h.setupToken.Matches(req.SetupToken) { + // Repeat the token in the log at the moment someone needs it: the + // boot line may have scrolled away, or users may have been deleted + // since boot. + h.logger.Warn("register: first-admin registration needs the setup token", + "setup_token", h.setupToken.Value()) + writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to create the first account")) + return + } + // Validate invite (skipped on empty-users state; skipped in 'open' mode). usedInviteToken := "" if userCount > 0 { @@ -232,3 +251,31 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { }, }) } + +// handleSetupStatus implements GET /api/auth/setup-status. It tells the +// register screen whether to ask for the setup token, i.e. whether no +// account exists yet. Public, since it is needed before anyone can sign in; +// "this server has no users" is not worth hiding from someone who could +// simply try to register. +func (h *handlers) handleSetupStatus(w http.ResponseWriter, r *http.Request) { + n, err := dbq.New(h.pool).CountUsers(r.Context()) + if err != nil { + writeErrWithLog(w, h.logger, "setup status: count users failed", apierror.Internal(err)) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"setup_required": n == 0}) +} + +// logSetupTokenIfNeeded writes the setup token to the log at boot when the +// instance has no accounts yet, with the instruction for using it. +func logSetupTokenIfNeeded(pool *pgxpool.Pool, logger *slog.Logger, token *auth.SetupToken) { + if pool == nil || logger == nil { + return + } + n, err := dbq.New(pool).CountUsers(context.Background()) + if err != nil || n > 0 { + return + } + logger.Warn("no accounts yet: open the web app, choose Create account, and enter this setup token to become the admin", + "setup_token", token.Value()) +} diff --git a/internal/api/auth_register_test.go b/internal/api/auth_register_test.go index 698caf5e..d64de07e 100644 --- a/internal/api/auth_register_test.go +++ b/internal/api/auth_register_test.go @@ -273,3 +273,75 @@ func TestRegister_FirstAdminRace(t *testing.T) { // not "exactly one" (which would require serializable isolation). t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount) } + +// With the gate on (as Mount sets it), the first account needs the setup +// token from the log; a missing or wrong one is refused and creates nothing. +func TestRegister_FirstUserNeedsSetupToken(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, _ := testHandlers(t) + resetUsers(t, h) + token, err := auth.NewSetupToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + h.setupToken = token + h.requireSetupToken = true + + register := func(body string) int { + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleRegister(rec, req) + return rec.Code + } + + if code := register(`{"username":"squatter","password":"abcd1234"}`); code != http.StatusForbidden { + t.Errorf("no token: status = %d, want 403", code) + } + if code := register(`{"username":"squatter","password":"abcd1234","setup_token":"wrong"}`); code != http.StatusForbidden { + t.Errorf("wrong token: status = %d, want 403", code) + } + var n int + if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatalf("a refused registration created %d account(s)", n) + } + + if code := register(`{"username":"operator","password":"abcd1234","setup_token":"` + token.Value() + `"}`); code != http.StatusOK { + t.Fatalf("right token: status = %d, want 200", code) + } + + // Once an account exists the token plays no part: the second user is + // governed by registration mode, not the setup token. + if _, err := h.pool.Exec(context.Background(), + `UPDATE registration_settings SET mode = 'open' WHERE id = true`); err != nil { + t.Fatalf("open mode: %v", err) + } + t.Cleanup(func() { + _, _ = h.pool.Exec(context.Background(), + `UPDATE registration_settings SET mode = 'invite_only' WHERE id = true`) + }) + if code := register(`{"username":"second","password":"abcd1234"}`); code != http.StatusOK { + t.Errorf("second user without token: status = %d, want 200", code) + } +} + +func TestSetupToken_MatchesOnlyItself(t *testing.T) { + tok, err := auth.NewSetupToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + if !tok.Matches(tok.Value()) { + t.Error("token does not match itself") + } + if tok.Matches("") || tok.Matches(tok.Value()+"x") { + t.Error("token matched something else") + } + var none *auth.SetupToken + if none.Matches("") || none.Matches("anything") { + t.Error("a nil token must fail closed") + } +} diff --git a/internal/auth/setup.go b/internal/auth/setup.go new file mode 100644 index 00000000..9d576be7 --- /dev/null +++ b/internal/auth/setup.go @@ -0,0 +1,46 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" +) + +// SetupToken guards the first-admin registration. Until the first account +// exists, register makes whoever calls it the admin, so a fresh instance on a +// public address belonged to whoever found it first. Now that call also has +// to carry this token, which is generated at startup and written only to the +// server log: proof that the caller can read the operator's logs. +// +// The token lives in memory. A restart mints a new one and logs it again, +// which is the behaviour wanted: an old token from a log line someone else +// saw stops working. +type SetupToken struct { + value string +} + +// NewSetupToken mints a 128-bit token. +func NewSetupToken() (*SetupToken, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return &SetupToken{value: hex.EncodeToString(b)}, nil +} + +// Value returns the token for logging. +func (t *SetupToken) Value() string { + if t == nil { + return "" + } + return t.value +} + +// Matches reports whether supplied is the token, in constant time. A nil +// token never matches anything, so a missing token fails closed. +func (t *SetupToken) Matches(supplied string) bool { + if t == nil || t.value == "" || supplied == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(t.value), []byte(supplied)) == 1 +} diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts index 49903a20..b8b26db2 100644 --- a/web/src/lib/auth/store.svelte.ts +++ b/web/src/lib/auth/store.svelte.ts @@ -47,10 +47,19 @@ export async function login(username: string, password: string): Promise { void sendTimezoneIfStale(); } +/** + * Whether the server has no accounts yet, in which case the first + * registration must carry the setup token printed in the server log. + */ +export async function getSetupStatus(): Promise<{ setup_required: boolean }> { + return api.get<{ setup_required: boolean }>('/api/auth/setup-status'); +} + export async function register(opts: { username: string; password: string; inviteToken?: string; + setupToken?: string; displayName?: string; }): Promise { const body: Record = { @@ -58,6 +67,7 @@ export async function register(opts: { password: opts.password, }; if (opts.inviteToken) body.invite_token = opts.inviteToken; + if (opts.setupToken) body.setup_token = opts.setupToken; if (opts.displayName) body.display_name = opts.displayName; const res = await api.post('/api/auth/register', body); setUser(res.user); diff --git a/web/src/routes/register/+page.svelte b/web/src/routes/register/+page.svelte index ae039203..dd37293a 100644 --- a/web/src/routes/register/+page.svelte +++ b/web/src/routes/register/+page.svelte @@ -1,7 +1,8 @@