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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" size="lg" className="w-full" rightIcon={<ArrowRight className="w-4 h-4" />}>
|
||||
<Button type="submit" variant="primary" size="lg" isLoading={isVerifyingToken} className="w-full" rightIcon={<ArrowRight className="w-4 h-4" />}>
|
||||
{t('setup.continue')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user