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.
This commit is contained in:
Luca
2026-07-02 15:18:22 +02:00
parent 3e69c5df3f
commit b0912c7427
6 changed files with 99 additions and 4 deletions
@@ -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: '[email protected]', 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)
+1
View File
@@ -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
+25
View File
@@ -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'),
+18 -1
View File
@@ -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 };