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.
103 lines
4.8 KiB
JavaScript
103 lines
4.8 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import express from 'express';
|
|
import session from 'express-session';
|
|
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
import tokensRouter from '../../src/routes/tokens.js';
|
|
import authRouter from '../../src/routes/auth.js';
|
|
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
|
|
import { hashPassword } from '../../src/auth/passwords.js';
|
|
|
|
async function app(pool) {
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
process.env.AUTH_ENABLED = 'true';
|
|
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
|
const a = express();
|
|
a.use(express.json());
|
|
a.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,
|
|
}));
|
|
a.use('/api/v1', requireUiHeader);
|
|
a.use('/api/v1/auth', authRouter);
|
|
a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
|
a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username }));
|
|
return new Promise(r => {
|
|
const srv = a.listen(0, '127.0.0.1', () => {
|
|
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loginCookie(baseUrl, u, p) {
|
|
const r = await fetch(baseUrl + '/api/v1/auth/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
body: JSON.stringify({ username: u, password: p }),
|
|
});
|
|
return (r.headers.get('set-cookie') || '').split(';')[0];
|
|
}
|
|
|
|
test('tokens: create returns the raw token exactly once; bearer of that token works; revoke 401s subsequent calls', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
|
|
const { baseUrl, close } = await app(pool);
|
|
try {
|
|
const cookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');
|
|
|
|
// Create
|
|
const create = await fetch(baseUrl + '/api/v1/auth/tokens', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
|
|
body: JSON.stringify({ name: 'Premiere panel' }),
|
|
});
|
|
assert.equal(create.status, 201);
|
|
const created = await create.json();
|
|
assert.match(created.token, /^dfl_[0-9a-f]{64}$/);
|
|
assert.equal(created.prefix, created.token.slice(0, 8));
|
|
|
|
// List — should NOT include the raw token, only the prefix.
|
|
const list = await fetch(baseUrl + '/api/v1/auth/tokens', { headers: { cookie } });
|
|
const rows = await list.json();
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].prefix, created.prefix);
|
|
assert.equal(rows[0].token, undefined);
|
|
|
|
// The raw token authenticates as a bearer.
|
|
const ping = await fetch(baseUrl + '/api/v1/protected/ping', {
|
|
headers: { authorization: 'Bearer ' + created.token },
|
|
});
|
|
assert.equal(ping.status, 200);
|
|
|
|
// Revoke.
|
|
const rev = await fetch(baseUrl + '/api/v1/auth/tokens/' + created.id, {
|
|
method: 'DELETE', headers: { cookie },
|
|
});
|
|
assert.equal(rev.status, 204);
|
|
|
|
// Same bearer now 401s.
|
|
const ping2 = await fetch(baseUrl + '/api/v1/protected/ping', {
|
|
headers: { authorization: 'Bearer ' + created.token },
|
|
});
|
|
assert.equal(ping2.status, 401);
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
|
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('bob-passphrase-12')]);
|
|
const { baseUrl, close } = await app(pool);
|
|
try {
|
|
const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');
|
|
const bobCookie = await loginCookie(baseUrl, 'bob', 'bob-passphrase-12');
|
|
const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie: aliceCookie },
|
|
body: JSON.stringify({ name: 'alice token' }),
|
|
})).json();
|
|
const r = await fetch(baseUrl + '/api/v1/auth/tokens/' + aliceTok.id, {
|
|
method: 'DELETE', headers: { cookie: bobCookie },
|
|
});
|
|
assert.equal(r.status, 404); // not found from bob's perspective
|
|
} finally { await close(); await pool.end(); }
|
|
});
|