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:
@@ -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