feat(mam-api): POST /auth/setup — first-run admin creation
This commit is contained in:
parent
49a9543942
commit
c9f9698b58
2 changed files with 107 additions and 0 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { DEV_USER_ID } from '../middleware/auth.js';
|
import { DEV_USER_ID } from '../middleware/auth.js';
|
||||||
|
import { hashPassword } from '../auth/passwords.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -19,5 +20,44 @@ router.get('/setup-required', async (_req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LEN = 12;
|
||||||
|
|
||||||
|
function badRequest(res, msg) { return res.status(400).json({ error: msg }); }
|
||||||
|
|
||||||
|
// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
|
||||||
|
router.post('/setup', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || typeof username !== 'string') return badRequest(res, 'username required');
|
||||||
|
if (!password || typeof password !== 'string') return badRequest(res, 'password required');
|
||||||
|
if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');
|
||||||
|
|
||||||
|
if ((await realUserCount()) > 0) {
|
||||||
|
return res.status(409).json({ error: 'setup already complete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO users (username, password_hash, display_name, role)
|
||||||
|
VALUES ($1, $2, $1, 'admin')
|
||||||
|
RETURNING id, username, display_name`,
|
||||||
|
[username.trim(), hash]
|
||||||
|
);
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
|
// Immediately log them in.
|
||||||
|
req.session.user_id = user.id;
|
||||||
|
req.session.first_seen_at = Date.now();
|
||||||
|
req.session.last_seen_at = Date.now();
|
||||||
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
|
|
||||||
|
res.json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
export { realUserCount };
|
export { realUserCount };
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import session from 'express-session';
|
||||||
import authRouter from '../../src/routes/auth.js';
|
import authRouter from '../../src/routes/auth.js';
|
||||||
|
|
||||||
async function appWithAuth(pool) {
|
async function appWithAuth(pool) {
|
||||||
|
|
@ -35,3 +36,69 @@ test('GET /auth/setup-required returns { required: false } once a real user exis
|
||||||
assert.deepEqual(await res.json(), { required: false });
|
assert.deepEqual(await res.json(), { required: false });
|
||||||
} finally { await close(); await pool.end(); }
|
} finally { await close(); await pool.end(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function appWithSession(pool) {
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
process.env.SESSION_SECRET = 'test';
|
||||||
|
process.env.AUTH_ENABLED = 'true';
|
||||||
|
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(session({
|
||||||
|
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||||||
|
secret: 'test', name: 'dragonflight.sid',
|
||||||
|
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||||
|
rolling: false, resave: false, saveUninitialized: false,
|
||||||
|
}));
|
||||||
|
app.use('/api/v1/auth', authRouter);
|
||||||
|
return new Promise(r => {
|
||||||
|
const srv = app.listen(0, '127.0.0.1', () => {
|
||||||
|
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('POST /auth/setup creates the first admin and returns a session cookie', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
const { baseUrl, close } = await appWithSession(pool);
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.user.username, 'admin');
|
||||||
|
assert.match(res.headers.get('set-cookie') || '', /dragonflight\.sid=/);
|
||||||
|
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE username='admin'`);
|
||||||
|
assert.equal(rows[0].n, 1);
|
||||||
|
} finally { await close(); await pool.end(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('existing', 'x')`);
|
||||||
|
const { baseUrl, close } = await appWithSession(pool);
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 409);
|
||||||
|
assert.equal((await res.json()).error, 'setup already complete');
|
||||||
|
} finally { await close(); await pool.end(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
const { baseUrl, close } = await appWithSession(pool);
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'short' }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
} finally { await close(); await pool.end(); }
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue