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.
90 lines
4.1 KiB
JavaScript
90 lines
4.1 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 usersRouter from '../../src/routes/users.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/users', requireAuth, usersRouter);
|
|
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 login(baseUrl, username, password) {
|
|
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, password }),
|
|
});
|
|
assert.equal(r.status, 200, 'login failed: ' + JSON.stringify(await r.json()));
|
|
return (r.headers.get('set-cookie') || '').split(';')[0];
|
|
}
|
|
|
|
test('users: list + create + delete + admin reset password', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', $1)`, [await hashPassword('admin-passphrase!!')]);
|
|
const { baseUrl, close } = await app(pool);
|
|
try {
|
|
const cookie = await login(baseUrl, 'admin', 'admin-passphrase!!');
|
|
|
|
// List
|
|
const list = await fetch(baseUrl + '/api/v1/auth/users', { headers: { cookie } });
|
|
assert.equal(list.status, 200);
|
|
const users0 = await list.json();
|
|
assert.ok(users0.find(u => u.username === 'admin'));
|
|
|
|
// Create
|
|
const created = await fetch(baseUrl + '/api/v1/auth/users', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
|
|
body: JSON.stringify({ username: 'bob', password: 'bob-passphrase!', display_name: 'Bob' }),
|
|
});
|
|
assert.equal(created.status, 201);
|
|
const bob = await created.json();
|
|
assert.equal(bob.username, 'bob');
|
|
|
|
// Admin reset password
|
|
const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
|
|
body: JSON.stringify({ new_password: 'a-fresh-passphrase' }),
|
|
});
|
|
assert.equal(reset.status, 204);
|
|
|
|
// Delete
|
|
const del = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id, {
|
|
method: 'DELETE', headers: { cookie },
|
|
});
|
|
assert.equal(del.status, 204);
|
|
} finally { await close(); await pool.end(); }
|
|
});
|
|
|
|
test('users: cannot delete the last real user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
|
const pool = await setupTestDb();
|
|
await pool.query(`INSERT INTO users (id, username, password_hash) VALUES (uuid_generate_v4(), 'solo', $1)`, [await hashPassword('only-user-here-12')]);
|
|
const { baseUrl, close } = await app(pool);
|
|
try {
|
|
const cookie = await login(baseUrl, 'solo', 'only-user-here-12');
|
|
const me = await (await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } })).json();
|
|
const r = await fetch(baseUrl + '/api/v1/auth/users/' + me.id, { method: 'DELETE', headers: { cookie } });
|
|
assert.equal(r.status, 409);
|
|
assert.equal((await r.json()).error, 'cannot delete last user');
|
|
} finally { await close(); await pool.end(); }
|
|
});
|