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
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:
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user