Admin: log in once, keep a session cookie instead of the key in the URL
The admin key used to stay in the URL fragment and was sent with every
request. Now the key is exchanged once for a server-side session:
- POST /api/admin/login {key} sets bingo_admin=<random token>
(HttpOnly, Secure, SameSite=Strict, no Path so it is scoped to the
admin API directory, also behind a subpath proxy)
- POST /api/admin/logout ends the session
- sessions live in memory as sha256(token) -> expiry, absolute lifetime
ADMIN_SESSION_HOURS (default 8); restart logs everyone out
- /admin#k=KEY still works as a login link; the page strips the fragment
from the URL and history before logging in, then shows plain /admin
- wrong key answers 401 after 500 ms; Bearer header still works for scripts
- COOKIE_SECURE=0 for plain-http testing only
Tests: 11 passing (login, cookie flags, logout, expiry, admin disabled).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhHiznVwtQ55ZSguTBWrxn
This commit is contained in:
1 parent
b6f0791243
commit
9b421c9b47
8 files changed
+320
-66
No files matched your search
+39
-29
@@ -2,18 +2,9 @@
|
||||
// Run: npm test (Node.js >= 22.13, no dependencies)
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { startServer, ADMIN_KEY } from './helpers.mjs';
|
||||
|
||||
const ADMIN_KEY = 'test-admin-key-1234567890';
|
||||
let proc, base, dataDir, week;
|
||||
|
||||
const freePort = () => new Promise((resolve) => {
|
||||
const s = createServer().listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
||||
});
|
||||
let server, base, week;
|
||||
|
||||
async function api(path, body, headers = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
@@ -27,24 +18,12 @@ const dev = (n) => `test0000-0000-0000-0000-${String(n).padStart(12, '0')}`;
|
||||
const mark = (d, cell, on = true, w = week) => api('/api/mark', { d, week: w, cell, on });
|
||||
|
||||
before(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'bingo-test-'));
|
||||
const port = await freePort();
|
||||
base = `http://127.0.0.1:${port}`;
|
||||
proc = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', 'server/server.mjs'], {
|
||||
env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, ADMIN_KEY, SEASON_START: '2020-01-06', SEASON_END: '2099-12-31', GOAL: '500', BACKUP_KEEP: '0' },
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try { if ((await fetch(base + '/healthz')).ok) break; } catch { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
server = await startServer();
|
||||
base = server.base;
|
||||
week = (await api('/api/total')).data.week;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
proc?.kill();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
after(() => server?.stop());
|
||||
|
||||
test('total reports season settings', async () => {
|
||||
const { status, data } = await api('/api/total');
|
||||
@@ -107,11 +86,12 @@ test('pulse answer is stored once per device and week', async () => {
|
||||
assert.equal((await api('/api/pulse', { d, week, answer: 'meh' })).status, 400);
|
||||
});
|
||||
|
||||
test('admin stats need the key and add up', async () => {
|
||||
assert.equal((await api('/api/admin/stats')).status, 403);
|
||||
assert.equal((await api('/api/admin/stats', null, { Authorization: 'Bearer wrong' })).status, 403);
|
||||
test('admin stats need a session or the key, and add up', async () => {
|
||||
assert.equal((await api('/api/admin/stats')).status, 401);
|
||||
assert.equal((await api('/api/admin/stats', null, { Authorization: 'Bearer wrong' })).status, 401);
|
||||
const { status, data } = await api('/api/admin/stats', null, { Authorization: `Bearer ${ADMIN_KEY}` });
|
||||
assert.equal(status, 200);
|
||||
assert.equal(data.session, null, 'Bearer access has no session');
|
||||
const w = data.weeks.find((x) => x.week === week);
|
||||
assert.equal(w.steps, w.cells + 5 * (w.rows + w.cols + w.diags));
|
||||
assert.equal(data.total, data.weeks.reduce((n, x) => n + x.steps, 0));
|
||||
@@ -119,6 +99,36 @@ test('admin stats need the key and add up', async () => {
|
||||
assert.equal(data.cells.length, 16);
|
||||
});
|
||||
|
||||
test('admin login: key once, then HttpOnly session cookie; logout ends it', async () => {
|
||||
const bad = await api('/api/admin/login', { key: 'wrong-key-wrong-key' });
|
||||
assert.equal(bad.status, 401);
|
||||
assert.equal(bad.headers.get('set-cookie'), null);
|
||||
|
||||
const ok = await api('/api/admin/login', { key: ADMIN_KEY });
|
||||
assert.equal(ok.status, 200);
|
||||
const setCookie = ok.headers.get('set-cookie');
|
||||
assert.match(setCookie, /^bingo_admin=[A-Za-z0-9_-]{40,};/);
|
||||
assert.match(setCookie, /HttpOnly/);
|
||||
assert.match(setCookie, /SameSite=Strict/);
|
||||
assert.match(setCookie, /Secure/);
|
||||
assert.match(setCookie, /Max-Age=28800/, 'default 8 hours');
|
||||
assert.doesNotMatch(setCookie, /Path=/, 'path defaults to /api/admin (or /<prefix>/api/admin behind nginx)');
|
||||
assert.doesNotMatch(setCookie, new RegExp(ADMIN_KEY), 'cookie never contains the key');
|
||||
const cookie = setCookie.split(';')[0];
|
||||
|
||||
const stats = await api('/api/admin/stats', null, { Cookie: cookie });
|
||||
assert.equal(stats.status, 200);
|
||||
const left = Date.parse(stats.data.session.expiresAt) - Date.now();
|
||||
assert.ok(left > 7.9 * 3600e3 && left <= 8 * 3600e3, 'expires in ~8 h');
|
||||
|
||||
assert.equal((await api('/api/admin/stats', null, { Cookie: 'bingo_admin=forged-token-value' })).status, 401);
|
||||
|
||||
const out = await api('/api/admin/logout', {}, { Cookie: cookie });
|
||||
assert.equal(out.status, 200);
|
||||
assert.match(out.headers.get('set-cookie'), /bingo_admin=; Max-Age=0/);
|
||||
assert.equal((await api('/api/admin/stats', null, { Cookie: cookie })).status, 401, 'old cookie is dead after logout');
|
||||
});
|
||||
|
||||
test('static pages and security headers', async () => {
|
||||
for (const p of ['/', '/admin', '/admin.html']) {
|
||||
const res = await fetch(base + p);
|
||||
|
||||
Reference in new issue
Block a user