feat(auth): first account on a new server needs the setup token from the server log (M462 #4982)
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:35:52 -04:00
co-authored by Claude Opus 5.5
parent 46194a609d
commit 2f3fbccab6
7 changed files with 276 additions and 16 deletions
+16
View File
@@ -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.
+50 -3
View File
@@ -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())
}
+72
View File
@@ -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")
}
}
+46
View File
@@ -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
}
+10
View File
@@ -47,10 +47,19 @@ export async function login(username: string, password: string): Promise<void> {
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<void> {
const body: Record<string, string> = {
@@ -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<LoginResponse>('/api/auth/register', body);
setUser(res.user);
+47 -12
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { goto } from '$app/navigation';
import { register } from '$lib/auth/store.svelte';
import { onMount } from 'svelte';
import { getSetupStatus, register } from '$lib/auth/store.svelte';
import { errCode } from '$lib/api/errors';
import { rateLimitMessage } from '$lib/api/client';
@@ -9,6 +10,19 @@
let password = $state('');
let confirmPassword = $state('');
let inviteToken = $state('');
let setupToken = $state('');
// True on a brand-new server: the first account becomes admin and must
// carry the setup token from the server log.
let setupRequired = $state(false);
onMount(async () => {
try {
setupRequired = (await getSetupStatus()).setup_required;
} catch {
// Unknown: leave the ordinary form. If a token turns out to be needed,
// the server says so and the error below explains where to find it.
}
});
let displayName = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
@@ -25,6 +39,8 @@
return 'That invite token is invalid, expired, or already used.';
case 'username_taken':
return 'That username is already taken.';
case 'setup_token_invalid':
return "That setup token doesn't match. Copy it from the server log; it changes each time the server restarts.";
default:
return 'Registration failed. Please try again.';
}
@@ -44,6 +60,7 @@
username,
password,
inviteToken: inviteToken || undefined,
setupToken: setupToken.trim() || undefined,
displayName: displayName || undefined,
});
await goto('/', { replaceState: true });
@@ -107,17 +124,35 @@
bind:value={confirmPassword}
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">
Invite token <span class="text-text-secondary opacity-60">(if required by your server)</span>
</span>
<input
type="text"
autocomplete="off"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={inviteToken}
/>
</label>
{#if setupRequired}
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">Setup token</span>
<input
type="text"
autocomplete="off"
spellcheck="false"
required
class="w-full rounded border border-border bg-background px-3 py-2 font-mono outline-none focus:border-accent"
bind:value={setupToken}
/>
<span class="mt-1 block text-xs text-text-secondary">
This is a new server, so this account will be its admin. The setup token is printed in the
server log (look for "setup_token").
</span>
</label>
{:else}
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">
Invite token <span class="text-text-secondary opacity-60">(if required by your server)</span>
</span>
<input
type="text"
autocomplete="off"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={inviteToken}
/>
</label>
{/if}
<button
type="submit"
+35 -1
View File
@@ -11,15 +11,17 @@ vi.mock('$app/navigation', () => ({
vi.mock('$lib/auth/store.svelte', () => ({
register: vi.fn(),
getSetupStatus: vi.fn().mockResolvedValue({ setup_required: false }),
user: { value: null }
}));
import RegisterPage from './+page.svelte';
import { register } from '$lib/auth/store.svelte';
import { getSetupStatus, register } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation';
afterEach(() => {
vi.clearAllMocks();
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: false });
});
describe('/register page', () => {
@@ -115,6 +117,38 @@ describe('/register page', () => {
await waitFor(() => expect(screen.getByRole('button', { name: /create account/i })).not.toBeDisabled());
});
test('on a new server, asks for the setup token instead of an invite and sends it', async () => {
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: true });
(register as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(RegisterPage);
const tokenField = await screen.findByLabelText(/setup token/i);
expect(screen.queryByLabelText(/invite token/i)).toBeNull();
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'operator' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.input(tokenField, { target: { value: ' 0123abcd ' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(register).toHaveBeenCalledWith(expect.objectContaining({ setupToken: '0123abcd' }));
});
});
test('explains a rejected setup token', async () => {
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: true });
(register as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'setup_token_invalid' });
render(RegisterPage);
const tokenField = await screen.findByLabelText(/setup token/i);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'operator' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.input(tokenField, { target: { value: 'nope' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toMatch(/server log/i);
});
});
test('login page has link to register', () => {
// The login page symmetrically has a "Register" link - tested here via the
// register page having a "Sign in" link back to /login.