feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets
Fresh installs need nothing in .env. See PR description for the full feature.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
|
||||
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
|
||||
// so setupService shares this test's db instance (see crmDb.js note).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let setupService;
|
||||
let getAppSetting;
|
||||
let upsertAppSetting;
|
||||
let app;
|
||||
|
||||
const VALID_PW = 'Str0ng-Passw0rd!';
|
||||
|
||||
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
|
||||
// service/util), or db.js binds to the default path instead of the temp one.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
||||
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('admin_users').del();
|
||||
await db('app_settings').where({ setting_key: 'setup_token' }).del();
|
||||
});
|
||||
|
||||
describe('setupService (first-run bootstrap)', () => {
|
||||
it('reports needsAdmin while no admin exists', async () => {
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('generates and persists a one-time token while no admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(token).toEqual(expect.any(String));
|
||||
expect(token.length).toBeGreaterThan(20);
|
||||
expect(await getAppSetting('setup_token')).toBe(token);
|
||||
// Idempotent — a second call returns the same token, not a fresh one.
|
||||
expect(await setupService.ensureSetupToken()).toBe(token);
|
||||
});
|
||||
|
||||
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
|
||||
// Regression guard for the SQLite-only miss: a bare token string is rejected
|
||||
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
|
||||
// value must be JSON-parseable and round-trip back to the token.
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
|
||||
expect(() => JSON.parse(row.setting_value)).not.toThrow();
|
||||
expect(JSON.parse(row.setting_value)).toBe(token);
|
||||
});
|
||||
|
||||
it('rejects a wrong token', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token: 'nope', email: '[email protected]', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('rejects a weak password', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: '[email protected]', password: 'weak' })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const result = await setupService.createInitialAdmin({
|
||||
token, email: '[email protected]', password: VALID_PW, ip: '203.0.113.7',
|
||||
});
|
||||
|
||||
expect(result.user.email).toBe('[email protected]'); // normalised
|
||||
expect(result.user.role.name).toBe('super_admin');
|
||||
expect(result.token).toEqual(expect.any(String));
|
||||
|
||||
const row = await db('admin_users').first();
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
expect(row.role_id).toBe(role.id);
|
||||
expect(row.password_hash).not.toBe(VALID_PW); // hashed
|
||||
|
||||
// One-time: token burned, status now complete.
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
|
||||
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
|
||||
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
|
||||
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
|
||||
});
|
||||
|
||||
it('refuses to create a second admin (setup already complete)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('ensureSetupToken clears any stale token once an admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
|
||||
// Simulate a stale token left in settings, then re-run the boot hook.
|
||||
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
|
||||
expect(await setupService.ensureSetupToken()).toBeNull();
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup routes', () => {
|
||||
it('GET /api/setup/status reports needsAdmin', async () => {
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token: 'nope', email: '[email protected]', password: VALID_PW });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token, email: '[email protected]', password: VALID_PW });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.user.role.name).toBe('super_admin');
|
||||
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin 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/admin')
|
||||
.send({ token, email: '[email protected]', password: VALID_PW });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,16 @@ exports.up = async function(knex) {
|
||||
// Initialize tables
|
||||
await initializeDatabase();
|
||||
|
||||
// Create default admin user if none exists
|
||||
// Create default admin user if none exists.
|
||||
//
|
||||
// Legacy path — only when ADMIN_PASSWORD is explicitly provided (keeps
|
||||
// existing docker-compose installs working unchanged). When it is NOT set,
|
||||
// we deliberately leave admin_users empty so the first-run setup wizard
|
||||
// (setupService / /setup) creates the admin in-browser — no ADMIN_PASSWORD
|
||||
// in .env. Existing deployments already ran this migration, so this only
|
||||
// affects fresh installs.
|
||||
const adminExists = await knex('admin_users').first();
|
||||
if (!adminExists) {
|
||||
if (!adminExists && process.env.ADMIN_PASSWORD) {
|
||||
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
|
||||
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
||||
|
||||
@@ -43,6 +43,7 @@ const galleryRoutes = require('./src/routes/gallery');
|
||||
const adminRoutes = require('./src/routes/admin');
|
||||
const adminAuthRoutes = require('./src/routes/adminAuth');
|
||||
const secureImagesRoutes = require('./src/routes/secureImages');
|
||||
const setupRoutes = require('./src/routes/setup');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -397,6 +398,7 @@ async function initializeRateLimiters() {
|
||||
app.use('/api/auth', authRateLimiter);
|
||||
app.use('/api/gallery/:slug/verify', authRateLimiter);
|
||||
app.use('/api/admin/auth/login', authRateLimiter);
|
||||
app.use('/api/setup/admin', authRateLimiter);
|
||||
}
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
@@ -690,6 +692,7 @@ app.get('/health', async (req, res) => {
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
@@ -999,6 +1002,16 @@ async function startServer() {
|
||||
logger.warn('Install-from-backup hook threw:', err.message);
|
||||
}
|
||||
|
||||
// First-run: surface a one-time setup token while no admin account exists.
|
||||
// Runs AFTER install-from-backup so a restored instance (which repopulates
|
||||
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
|
||||
let setupToken = null;
|
||||
try {
|
||||
setupToken = await require('./src/services/setupService').ensureSetupToken();
|
||||
} catch (err) {
|
||||
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
|
||||
}
|
||||
|
||||
// Start backup service
|
||||
await startBackupService();
|
||||
|
||||
@@ -1014,6 +1027,13 @@ async function startServer() {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
||||
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
|
||||
// First-run: print the one-time setup token to STDOUT (the file logger
|
||||
// doesn't reach `docker logs`), as the last + most visible thing at boot.
|
||||
if (setupToken) {
|
||||
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
|
||||
const line = '='.repeat(64);
|
||||
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
// Public first-run setup endpoints. UNAUTHENTICATED by design — they exist so a
|
||||
// fresh instance can create its first admin from the browser (no ADMIN_PASSWORD
|
||||
// in .env). Both are hard-gated on "no admin exists yet", and POST /admin also
|
||||
// requires the one-time setup token, so they self-close after setup. The POST is
|
||||
// rate-limited at the mount point in server.js (authRateLimiter).
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const setupService = require('../services/setupService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
res.json(await setupService.getSetupStatus());
|
||||
} catch (err) {
|
||||
logger.error('[setup] status failed', { error: err.message });
|
||||
res.status(500).json({ error: 'Failed to read setup status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/admin', [
|
||||
body('token').notEmpty().withMessage('Setup token is required'),
|
||||
body('email').isEmail().withMessage('A valid email is required'),
|
||||
body('password').notEmpty().withMessage('Password is required'),
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { token, email, password } = req.body;
|
||||
const result = await setupService.createInitialAdmin({
|
||||
token,
|
||||
email,
|
||||
password,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
setAdminAuthCookie(res, result.token);
|
||||
// Token delivered via HttpOnly cookie only (mirrors admin login).
|
||||
res.status(201).json({ user: result.user });
|
||||
} catch (err) {
|
||||
if (err.statusCode) {
|
||||
return res.status(err.statusCode).json({ error: err.message });
|
||||
}
|
||||
logger.error('[setup] createInitialAdmin failed', { error: err.message });
|
||||
return res.status(500).json({ error: 'Setup failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
|
||||
const { ValidationError, ConflictError } = require('../utils/errors');
|
||||
const { validatePassword, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
// First-run bootstrap. The app boots with NO admin account and no
|
||||
// ADMIN_PASSWORD in the environment; the first browser visit creates the admin.
|
||||
// That create call is guarded by a one-time setup token, generated at boot
|
||||
// while no admin exists and printed to the logs (+ a best-effort data/SETUP_TOKEN
|
||||
// file). The token is ALWAYS required and burned on first use, so the endpoint
|
||||
// is permanently closed once setup is done — safe even on a public IP.
|
||||
const SETUP_TOKEN_KEY = 'setup_token';
|
||||
|
||||
async function noAdminExists() {
|
||||
const row = await db('admin_users').count({ c: '*' }).first();
|
||||
return Number(row?.c || 0) === 0;
|
||||
}
|
||||
|
||||
// Public status the /setup gate reads. Deliberately leaks nothing beyond
|
||||
// "is the instance still waiting for its first admin".
|
||||
async function getSetupStatus() {
|
||||
const needsAdmin = await noAdminExists();
|
||||
return { needsAdmin, complete: !needsAdmin };
|
||||
}
|
||||
|
||||
// Logs are the source of truth; the file is a convenience for operators who
|
||||
// reach a shell more easily than the container log view (e.g. `cat data/SETUP_TOKEN`).
|
||||
function setupTokenFilePath() {
|
||||
const dir = process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data');
|
||||
return path.join(dir, 'SETUP_TOKEN');
|
||||
}
|
||||
|
||||
// Called once at startup. Idempotent: generates + surfaces a token only while
|
||||
// the instance still needs an admin, and clears any stale token afterwards.
|
||||
// Clear the token everywhere — the app_settings row AND the on-disk file — so a
|
||||
// completed (or restored) install leaves no stale token behind.
|
||||
async function clearSetupToken() {
|
||||
await upsertAppSetting(SETUP_TOKEN_KEY, null, 'string');
|
||||
try { fs.unlinkSync(setupTokenFilePath()); } catch (_) { /* file may be absent — best-effort */ }
|
||||
}
|
||||
|
||||
async function ensureSetupToken() {
|
||||
if (!(await noAdminExists())) {
|
||||
await clearSetupToken();
|
||||
return null;
|
||||
}
|
||||
let token = await getAppSetting(SETUP_TOKEN_KEY);
|
||||
if (!token) {
|
||||
token = crypto.randomBytes(24).toString('base64url');
|
||||
// app_settings.setting_value is JSON on Postgres — store JSON-stringified
|
||||
// (getAppSetting JSON.parses on read). A raw string is rejected by jsonb.
|
||||
await upsertAppSetting(SETUP_TOKEN_KEY, JSON.stringify(token), 'string');
|
||||
}
|
||||
logger.warn(`[setup] No admin account yet — open /admin to finish setup. One-time setup token: ${token}`);
|
||||
try {
|
||||
const file = setupTokenFilePath();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
|
||||
} catch (err) {
|
||||
logger.warn(`[setup] Could not write setup token file (logs still have it): ${err.message}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Constant-time compare so the token can't be recovered by timing the response.
|
||||
function tokensMatch(provided, expected) {
|
||||
if (!provided || !expected) return false;
|
||||
const a = Buffer.from(String(provided));
|
||||
const b = Buffer.from(String(expected));
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
if (!(await noAdminExists())) {
|
||||
throw new ConflictError('Setup already completed — an admin account exists');
|
||||
}
|
||||
const expected = await getAppSetting(SETUP_TOKEN_KEY);
|
||||
if (!tokensMatch(token, expected)) {
|
||||
throw new ValidationError('Invalid setup token', 'token');
|
||||
}
|
||||
|
||||
const cleanEmail = String(email || '').trim().toLowerCase();
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(cleanEmail)) {
|
||||
throw new ValidationError('A valid email address is required', 'email');
|
||||
}
|
||||
const strength = validatePassword(password);
|
||||
if (!strength.valid) {
|
||||
throw new ValidationError(strength.errors[0] || 'Password does not meet requirements', 'password');
|
||||
}
|
||||
|
||||
const role = await db('roles').where('name', 'super_admin').first();
|
||||
if (!role) {
|
||||
throw new ConflictError('super_admin role missing — database not initialised');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: cleanEmail,
|
||||
email: cleanEmail,
|
||||
password_hash: passwordHash,
|
||||
role_id: role.id,
|
||||
is_active: formatBoolean(true),
|
||||
must_change_password: formatBoolean(false),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = inserted[0]?.id || inserted[0];
|
||||
|
||||
// Burn the one-time token (DB + file) — the endpoint is now permanently closed.
|
||||
await clearSetupToken();
|
||||
logger.info(`[setup] Initial super_admin created (id=${id}, email=${cleanEmail})`);
|
||||
|
||||
const authToken = jwt.sign(
|
||||
{ id, username: cleanEmail, type: 'admin', role: role.name, ip: ip || null, loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
return {
|
||||
token: authToken,
|
||||
user: {
|
||||
id,
|
||||
username: cleanEmail,
|
||||
email: cleanEmail,
|
||||
role: { name: role.name, displayName: role.display_name },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getSetupStatus, ensureSetupToken, createInitialAdmin };
|
||||
@@ -3,6 +3,21 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Machine secrets (JWT/DB/Redis): if not supplied via the environment, read them
|
||||
# from the generated secret files that the compose `secrets-init` service writes
|
||||
# to /run/secrets. Explicit env ALWAYS wins, so installs that set
|
||||
# JWT_SECRET/DB_PASSWORD/REDIS_PASSWORD in .env are unaffected. Runs before the
|
||||
# root -> nodejs re-exec so the exported values survive su-exec.
|
||||
for _pair in JWT_SECRET:jwt_secret DB_PASSWORD:db_password REDIS_PASSWORD:redis_password; do
|
||||
_var="${_pair%%:*}"
|
||||
_file="/run/secrets/${_pair##*:}"
|
||||
eval "_cur=\${$_var:-}"
|
||||
if [ -z "$_cur" ] && [ -s "$_file" ]; then
|
||||
export "$_var=$(cat "$_file")"
|
||||
fi
|
||||
done
|
||||
unset _pair _var _file _cur
|
||||
|
||||
# Permission handling (#484): the image starts as root so this script can
|
||||
# chown bind-mounted host volumes to UID 1001 (nodejs) before dropping
|
||||
# privileges via su-exec. This avoids the fresh-install restart loop where
|
||||
|
||||
Reference in New Issue
Block a user