Fixes three issues in the authentication system:
C1: Add boot-time warning when AUTH_ENABLED=true but TRUST_PROXY!=true.
Without TRUST_PROXY=true behind nginx, req.ip becomes the proxy IP for all
clients, collapsing per-IP rate limiting into a shared pool. Operators must
explicitly set TRUST_PROXY=true to make per-IP rate limiting effective.
C2: Mount requireUiHeader middleware in test helpers (auth.test.js,
users.test.js, tokens.test.js). The CSRF header validation was not being
exercised in the test suite. Tests now send X-Requested-With: dragonflight-ui
headers that are actually validated by the middleware.
I1: Implement bounded rate-limit Map with MAX_ENTRIES=10000 and LRU eviction.
Unbounded Maps are vulnerable to spray attacks: attackers can force memory
exhaustion by requesting with distinct IPs. Now we evict the oldest entry
(by insertion order) when the map reaches capacity.
246 lines
12 KiB
JavaScript
246 lines
12 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
import express from 'express';
|
|
import session from 'express-session';
|
|
import authRouter from '../../src/routes/auth.js';
|
|
import { hashPassword } from '../../src/auth/passwords.js';
|
|
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
|
|
|
|
async function appWithAuth(pool) {
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
const app = express();
|
|
app.use(express.json());
|
|
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('GET /auth/setup-required returns { required: true } on empty users (modulo dev seed)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
const { baseUrl, close } = await appWithAuth(pool);
|
|
try {
|
|
const res = await fetch(baseUrl + '/api/v1/auth/setup-required');
|
|
assert.equal(res.status, 200);
|
|
assert.deepEqual(await res.json(), { required: true });
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
test('GET /auth/setup-required returns { required: false } 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 ('admin', 'x')`);
|
|
const { baseUrl, close } = await appWithAuth(pool);
|
|
try {
|
|
const res = await fetch(baseUrl + '/api/v1/auth/setup-required');
|
|
assert.deepEqual(await res.json(), { required: false });
|
|
} 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', requireUiHeader);
|
|
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', 'X-Requested-With': 'dragonflight-ui' },
|
|
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', 'X-Requested-With': 'dragonflight-ui' },
|
|
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', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: 'admin', password: 'short' }),
|
|
});
|
|
assert.equal(res.status, 400);
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
async function appWithSessionAndMe(pool) {
|
|
// Same as appWithSession but also mounts a tiny /me endpoint behind requireAuth
|
|
// so we can exercise the round-trip: login → cookie sent → /me 200.
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
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', requireUiHeader);
|
|
app.use('/api/v1/auth', authRouter);
|
|
app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
|
|
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/login with valid creds → 200 + cookie, and the cookie unlocks subsequent requests (regression: redirect loop)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
const hash = await hashPassword('correct-horse-battery');
|
|
await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
|
|
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
|
try {
|
|
// 1. Login.
|
|
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
|
});
|
|
assert.equal(loginRes.status, 200);
|
|
const setCookie = loginRes.headers.get('set-cookie');
|
|
assert.match(setCookie || '', /dragonflight\.sid=/, 'expected Set-Cookie with dragonflight.sid');
|
|
|
|
// 2. The SAME cookie must unlock the next request. This is the bug that
|
|
// produced the original redirect loop — login returned 200 but no cookie
|
|
// was persisted, so the next request 401'd and the SPA bounced.
|
|
const meRes = await fetch(baseUrl + '/api/v1/protected/me', {
|
|
headers: { cookie: setCookie.split(';')[0] },
|
|
});
|
|
assert.equal(meRes.status, 200, 'POST /login returned 200 but the cookie did not unlock /me — this is the regression');
|
|
assert.equal((await meRes.json()).user.username, 'alice');
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
test('POST /auth/login with wrong password → 401 + generic message (no enumeration)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
const hash = await hashPassword('correct-horse-battery');
|
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
|
|
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
|
try {
|
|
const r1 = await fetch(baseUrl + '/api/v1/auth/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: 'alice', password: 'wrong' }),
|
|
});
|
|
const r2 = await fetch(baseUrl + '/api/v1/auth/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: 'nobody', password: 'whatever-long-enough' }),
|
|
});
|
|
assert.equal(r1.status, 401);
|
|
assert.equal(r2.status, 401);
|
|
const e1 = (await r1.json()).error, e2 = (await r2.json()).error;
|
|
assert.equal(e1, 'invalid credentials');
|
|
assert.equal(e2, 'invalid credentials'); // identical message — no enumeration
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
test('POST /auth/logout destroys the session row and the cookie no longer unlocks /me', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
const hash = await hashPassword('correct-horse-battery');
|
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
|
|
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
|
try {
|
|
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
|
});
|
|
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
|
|
|
|
const logoutRes = await fetch(baseUrl + '/api/v1/auth/logout', {
|
|
method: 'POST', headers: { cookie },
|
|
});
|
|
assert.equal(logoutRes.status, 204);
|
|
|
|
const meRes = await fetch(baseUrl + '/api/v1/protected/me', { headers: { cookie } });
|
|
assert.equal(meRes.status, 401);
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
test('GET /auth/me returns the authed user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
const hash = await hashPassword('correct-horse-battery');
|
|
await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
|
|
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
|
try {
|
|
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
|
});
|
|
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
|
|
const me = await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } });
|
|
assert.equal(me.status, 200);
|
|
const body = await me.json();
|
|
assert.equal(body.username, 'alice');
|
|
assert.equal(body.display_name, 'Alice');
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
test('POST /auth/password rotates the password when current is correct', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
const hash = await hashPassword('correct-horse-battery');
|
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
|
|
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
|
try {
|
|
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
|
});
|
|
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
|
|
const change = await fetch(baseUrl + '/api/v1/auth/password', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
|
|
body: JSON.stringify({ current_password: 'correct-horse-battery', new_password: 'brand-new-passphrase' }),
|
|
});
|
|
assert.equal(change.status, 204);
|
|
// Wrong current → 400
|
|
const wrong = await fetch(baseUrl + '/api/v1/auth/password', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
|
|
body: JSON.stringify({ current_password: 'no', new_password: 'another-good-passphrase' }),
|
|
});
|
|
assert.equal(wrong.status, 400);
|
|
} finally { await close(); await pool.end(); }
|
|
});
|