From b0912c74276ad2489a8d199739d6eee2e8dabf53 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:18:22 +0200 Subject: [PATCH] feat(setup): validate setup token at step 1 before advancing Previously "Continue" on the token step only checked the field was non-empty; a wrong token wasn't caught until the final submit, after the user had filled in email + password. Add a non-burning verify: - backend: POST /setup/verify-token constant-time compares the token without consuming it (createInitialAdmin still claims it atomically on submit), gated on no-admin-exists and rate-limited like /setup/admin. - frontend: step-1 "Continue" calls verifyToken and only advances on a valid token; a wrong token shows the invalidToken error on the field, 429 -> too-many-attempts, 409 -> redirect to login. Adds integration tests for accept-without-burn / reject / closed-once-set. --- .../integration/setupService.test.js | 23 ++++++++++++++++ backend/server.js | 1 + backend/src/routes/setup.js | 25 +++++++++++++++++ backend/src/services/setupService.js | 19 ++++++++++++- frontend/src/pages/SetupPage.tsx | 27 ++++++++++++++++--- frontend/src/services/setup.service.ts | 8 ++++++ 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/integration/setupService.test.js b/backend/__tests__/integration/setupService.test.js index 2953d7af..0f48f48d 100644 --- a/backend/__tests__/integration/setupService.test.js +++ b/backend/__tests__/integration/setupService.test.js @@ -142,6 +142,29 @@ describe('setup routes', () => { expect(res.body).toEqual({ needsAdmin: true, complete: false }); }); + it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => { + const token = await setupService.ensureSetupToken(); + const res = await request(app).post('/api/setup/verify-token').send({ token }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ valid: true }); + // Token is NOT consumed — it still works for the actual create. + expect(await getAppSetting('setup_token')).toBe(token); + }); + + it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => { + await setupService.ensureSetupToken(); + const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' }); + expect(res.status).toBe(400); + expect(res.body.field).toBe('token'); + }); + + it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => { + const token = await setupService.ensureSetupToken(); + await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW }); + const res = await request(app).post('/api/setup/verify-token').send({ token }); + expect(res.status).toBe(409); + }); + it('POST /api/setup/admin rejects a wrong token (400)', async () => { await setupService.ensureSetupToken(); const res = await request(app) diff --git a/backend/server.js b/backend/server.js index 8c2c20ca..1bf04dc7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -399,6 +399,7 @@ async function initializeRateLimiters() { app.use('/api/gallery/:slug/verify', authRateLimiter); app.use('/api/admin/auth/login', authRateLimiter); app.use('/api/setup/admin', authRateLimiter); + app.use('/api/setup/verify-token', authRateLimiter); } // Note: Rate limiters will be initialized after database connection diff --git a/backend/src/routes/setup.js b/backend/src/routes/setup.js index c8defa43..1acafbbf 100644 --- a/backend/src/routes/setup.js +++ b/backend/src/routes/setup.js @@ -23,6 +23,31 @@ router.get('/status', async (req, res) => { } }); +// Step-1 pre-flight: validate the setup token without consuming it, so the +// two-step wizard can block "Continue" on a wrong token. Rate-limited at the +// mount point in server.js (authRateLimiter), same as POST /admin. +router.post('/verify-token', [ + body('token').notEmpty().withMessage('Setup token is required'), +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { + const valid = await setupService.verifySetupToken(req.body.token); + if (!valid) { + return res.status(400).json({ error: 'Invalid setup token', field: 'token' }); + } + return res.json({ valid: true }); + } catch (err) { + if (err.statusCode) { + return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined }); + } + logger.error('[setup] verifyToken failed', { error: err.message }); + return res.status(500).json({ error: 'Setup failed' }); + } +}); + router.post('/admin', [ body('token').notEmpty().withMessage('Setup token is required'), body('email').isEmail().withMessage('A valid email is required'), diff --git a/backend/src/services/setupService.js b/backend/src/services/setupService.js index 8f199f6c..19cf55ef 100644 --- a/backend/src/services/setupService.js +++ b/backend/src/services/setupService.js @@ -80,6 +80,23 @@ function tokensMatch(provided, expected) { return crypto.timingSafeEqual(a, b); } +// Pre-flight check for the two-step wizard: lets step 1 confirm the token is +// valid before advancing to the account step, so a wrong token is caught at +// "Continue" rather than after the user has filled in email + password. Does +// NOT burn the token — createInitialAdmin still claims it atomically on submit. +// Rate-limited at the mount point (same as /admin) so it can't be used to +// brute-force the token; the token is also 24 random bytes, so guessing is +// infeasible regardless. +async function verifySetupToken(token) { + if (!(await noAdminExists())) { + // Setup already finished — treat the endpoint as closed (the client + // redirects to login on a 409). + throw new ConflictError('Setup already completed — an admin account exists'); + } + const expected = await getAppSetting(SETUP_TOKEN_KEY); + return tokensMatch(token, expected); +} + // Creates the first admin as super_admin (the highest role) and returns a // ready-to-set admin JWT so the browser flows straight into the wizard. async function createInitialAdmin({ token, email, password, ip }) { @@ -156,4 +173,4 @@ async function createInitialAdmin({ token, email, password, ip }) { }; } -module.exports = { getSetupStatus, ensureSetupToken, createInitialAdmin }; +module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin }; diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 6b4101a4..789a8db4 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -41,6 +41,7 @@ export const SetupPage: React.FC = () => { const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [isVerifyingToken, setIsVerifyingToken] = useState(false); const [copied, setCopied] = useState(false); const [errors, setErrors] = useState>({}); @@ -92,12 +93,32 @@ export const SetupPage: React.FC = () => { return Object.keys(next).length === 0; }; - const handleTokenContinue = (e: React.FormEvent) => { + const handleTokenContinue = async (e: React.FormEvent) => { e.preventDefault(); toast.dismiss(); if (!validateToken()) return; + // Verify the token server-side before advancing — a wrong token is caught + // here at "Continue" rather than after the user has filled in the account + // step. The token is checked, not consumed; createInitialAdmin still burns + // it atomically on final submit. + setIsVerifyingToken(true); setErrors({}); - setStep('account'); + try { + await setupService.verifyToken(form.token.trim()); + setStep('account'); + } catch (error: any) { + const httpStatus = error.response?.status; + if (httpStatus === 429) { + toast.error(t('setup.tooManyAttempts')); + } else if (httpStatus === 409) { + // Someone else finished setup first — send to login. + navigate('/admin/login', { replace: true }); + } else { + setErrors({ token: t('setup.invalidToken') }); + } + } finally { + setIsVerifyingToken(false); + } }; const handleSubmit = async (e: React.FormEvent) => { @@ -246,7 +267,7 @@ export const SetupPage: React.FC = () => { - diff --git a/frontend/src/services/setup.service.ts b/frontend/src/services/setup.service.ts index ffe06c3e..b069b9b3 100644 --- a/frontend/src/services/setup.service.ts +++ b/frontend/src/services/setup.service.ts @@ -25,6 +25,14 @@ export const setupService = { return response.data; }, + // Step-1 pre-flight: confirm the token is valid before advancing to the + // account step. Rejects (400, field: 'token') on a wrong token without + // burning it. Throws on non-2xx so the caller can branch on the status. + async verifyToken(token: string): Promise<{ valid: boolean }> { + const response = await api.post<{ valid: boolean }>('/setup/verify-token', { token }); + return response.data; + }, + async createInitialAdmin(input: CreateInitialAdminInput): Promise<{ user: SetupAdminUser }> { // Admin JWT is returned as an HttpOnly cookie (mirrors login); body carries the user. const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);