diff --git a/bingo/.env.example b/bingo/.env.example index c1d3f1c..cfff487 100644 --- a/bingo/.env.example +++ b/bingo/.env.example @@ -4,6 +4,9 @@ # Пустой ключ = админка выключена. ADMIN_KEY= +# Сколько часов действует вход в /admin (с момента входа, без продления). Перезапуск контейнера тоже завершает вход. +ADMIN_SESSION_HOURS=8 + # Сезон TIMEZONE=Europe/Moscow # в каком поясе карточка обнуляется в понедельник 00:00 SEASON_START=2026-11-02 # первый день, когда отметки идут в батарейку diff --git a/bingo/CLAUDE.md b/bingo/CLAUDE.md index e43cb0f..a883b50 100644 --- a/bingo/CLAUDE.md +++ b/bingo/CLAUDE.md @@ -54,7 +54,7 @@ Browser ──https──▶ nginx :443 (host) ──http──▶ 127.0.0.1:808 ``` public/index.html page: card, row highlight + confetti, battery, weekly pulse question, demo mode -public/admin.html organiser stats, CSV export; key passed in URL fragment (#k=...) +public/admin.html organiser stats, CSV export; key login → session cookie, logout public/fonts/*.woff2 Unbounded + Manrope, Cyrillic + Latin subsets server/server.mjs HTTP server, API, season/week logic, security headers, daily backups server/db.mjs openDb(), LINES_SQL (row/col/diag detection), LINE_BONUS = 5 @@ -91,6 +91,8 @@ rejects marks (`season_closed`). `http://localhost:8080/#demo` forces demo mode | Var | Default | Notes | |---|---|---| | `ADMIN_KEY` / `ADMIN_KEY_FILE` | empty | empty = admin disabled; < 16 chars = refuses to start | +| `ADMIN_SESSION_HOURS` | `8` | admin session lifetime from login, absolute (no sliding); 0 < h ≤ 720 | +| `COOKIE_SECURE` | on | `0` drops the `Secure` cookie flag — only for plain-http testing | | `TIMEZONE` | `Europe/Moscow` | weekly reset is Monday 00:00 in this zone | | `SEASON_START` | `2026-11-02` | before it: "practice mode", marks not accepted by server | | `SEASON_END` | `2026-12-21` | inclusive; after it: season closed | @@ -126,7 +128,9 @@ Invalid config makes the server exit at startup with a clear message. | `GET /api/me?d=` | — | `{week, cells:[...], pulse}` — this device's current week | | `POST /api/mark` | `{d, week, cell 1–16, on: bool}` | `{ok, week, total, cells}`; 409 `week_changed`, 403 `season_closed`, 400 validation | | `POST /api/pulse` | `{d, week, answer: full/mid/low}` | `{ok, answer}`; one answer per device per week, can be changed | -| `GET /api/admin/stats` | `Authorization: Bearer ` | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts | +| `POST /api/admin/login` | `{key}` | 200 + `Set-Cookie: bingo_admin=`; 401 `bad_key` (after 500 ms); 403 `admin_disabled` | +| `POST /api/admin/logout` | — | deletes the session, clears the cookie | +| `GET /api/admin/stats` | session cookie, or `Authorization: Bearer ` for scripts | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts, `session.expiresAt`; 401 without auth | | `GET /healthz` | — | `{ok:true}`; blocked by nginx from outside | Device id `d` = random UUID generated in the browser and kept in localStorage (`balance2-bingo-v1`), @@ -141,6 +145,19 @@ regex `^[A-Za-z0-9-]{16,64}$`. Request bodies are capped at 2 KB (413 above that - Only the team total is public. The admin page shows aggregates only. - Accepted trade-off: no anti-cheat (someone can use 10 browsers). Don't add fingerprinting or IP limits that store data. +## Admin authentication + +- The key is never kept in the URL. `/admin#k=KEY` still works as a login link: the page immediately + `history.replaceState`s the fragment away, POSTs the key to `/api/admin/login`, then shows plain `/admin`. + The login form does the same without touching the URL. +- The server answers with `bingo_admin=<32 random bytes, base64url>; Max-Age; HttpOnly; SameSite=Strict; Secure`. + **No `Path` on purpose**: the browser defaults it to the request directory (`/api/admin`, or + `//api/admin` behind the subpath nginx config), so the cookie only goes to admin API calls. +- Sessions live in an in-memory `Map` keyed by sha256(token) → expiry (max 200, oldest evicted). + Absolute lifetime `ADMIN_SESSION_HOURS` (default 8 h), no sliding. A container restart logs everyone out — accepted. +- Wrong key → 401 after a 500 ms delay. The key is compared with `timingSafeEqual` over sha256 digests. +- `Authorization: Bearer ` is still accepted on `/api/admin/stats` for curl/scripts. + ## Teams embedding gotcha The page must be embeddable as a Teams "Website" tab. The server sends @@ -166,7 +183,11 @@ A test asserts the header is absent. ## Verification status -- `npm test` passes (scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse, admin auth/totals, headers). +- `npm test` passes, 11 tests: scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse, + admin totals, admin login/cookie flags/logout, session expiry, admin disabled, COOKIE_SECURE=0, headers. +- Admin login flow verified in a real browser over https through both nginx configs (30 Oct 2026): + key removed from URL/history, cookie HttpOnly + Secure + SameSite=Strict with path `/api/admin` + (or `/bingo/api/admin`), survives reload, logout ends it, wrong key shows an error. - Manually verified (17–18 Oct 2026): desktop + 375px + 320px layouts, no horizontal overflow; pre-season / overdrive / weekly reset states; container healthy, data persists across restart and `down`/`up`, runs with network disconnected, non-root, read-only rootfs. @@ -185,7 +206,8 @@ A test asserts the header is absent. (or `certbot --nginx -d `), drop `listen [::]` lines if no IPv6; `nginx -t && systemctl reload nginx`. 4. Dry run: temporarily set `SEASON_START` to a past Monday, test on phone + desktop + Teams tab, then restore `2026-11-02`, `docker compose up -d`, `docker compose exec bingo node server/cli.mjs reset --yes`. -5. Links: team → `https:///`; organisers → `https:///admin#k=`. +5. Links: team → `https:///`; organisers → `https:///admin` (log in with the key; + `/admin#k=` also works as a one-click login link and is stripped from the URL). 6. Backups: `docker compose cp bingo:/data/backups ./backups` occasionally, or back up the `balance-bingo_bingo-data` volume. ## Conventions diff --git a/bingo/README.md b/bingo/README.md index 66f15a9..79dd000 100644 --- a/bingo/README.md +++ b/bingo/README.md @@ -233,14 +233,18 @@ curl -s https://bingo.example.com/api/total ## 3. Ссылки - **Для команды** — `https://bingo.example.com/`. Закрепите в канале Teams сообщением или вкладкой «Веб-сайт». -- **Для организаторов** — `https://bingo.example.com/admin#k=ВАШ_ADMIN_KEY`. Часть после `#` не уходит - на сервер и не попадает в журналы, ключ передаётся только в заголовке запроса. +- **Для организаторов** — `https://bingo.example.com/admin`, вход по ключу из `.env`. Можно и ссылкой + для входа `https://bingo.example.com/admin#k=ВАШ_ADMIN_KEY`: страница сразу убирает ключ из адреса + и истории браузера, обменивает его на сессию, и дальше открыт обычный `/admin`. Такую ссылку не стоит + пересылать в чатах — она сама по себе пароль. ## Настройки (`.env`) | Параметр | По умолчанию | Что значит | |---|---|---| | `ADMIN_KEY` | пусто | Ключ админки, от 16 символов. Пусто — админка выключена | +| `ADMIN_SESSION_HOURS` | `8` | Сколько часов действует вход в админку, см. «Админка» | +| `COOKIE_SECURE` | включено | `0` — cookie входа без флага Secure. Только для проверки по http, не в работе | | `TIMEZONE` | `Europe/Moscow` | В каком поясе карточка обнуляется в понедельник 00:00 | | `SEASON_START` | `2026-11-02` | Первый день, когда отметки идут в батарейку | | `SEASON_END` | `2026-12-21` | Последний такой день (включительно) | @@ -326,15 +330,33 @@ docker compose up -d --build # данные в томе сохраняютс - Порт слушает только `127.0.0.1`. - Тело запроса ограничено: 2 КБ в приложении, 8 КБ в nginx. - У контейнера есть healthcheck (`/healthz`). -- Админка закрыта ключом. Если хочется закрыть её ещё и по сети, добавьте в nginx +- Админка: вход по ключу, дальше сессия в cookie (подробности — в разделе «Админка»). Если хочется закрыть её ещё и по сети, добавьте в nginx `location /admin { allow <сеть офиса/VPN>; deny all; proxy_pass … }`. ## Админка -`/admin#k=КЛЮЧ` показывает батарейку и шаги по неделям: клетки, собранные ряды, столбцы и диагонали, +`/admin` показывает батарейку и шаги по неделям: клетки, собранные ряды, столбцы и диагонали, число устройств. Там же ответы на мини-вопрос по неделям и популярность каждой клетки. Три кнопки -выгружают CSV (разделитель `;`, UTF-8 с BOM — открывается в Excel без мастера импорта). Ключ меняется в `.env`, -затем `docker compose up -d`. +выгружают CSV (разделитель `;`, UTF-8 с BOM — открывается в Excel без мастера импорта). + +**Вход.** Ключ вводится один раз в форме на странице (или через ссылку `/admin#k=КЛЮЧ` — ключ сразу +убирается из адреса). Сервер проверяет его и ставит cookie сессии: + +- в cookie лежит случайный токен, а не ключ; сервер хранит только хеш токена; +- `HttpOnly` — скрипты страницы его не видят, `Secure` — только по https, `SameSite=Strict` — не уходит + с запросами с чужих сайтов; +- cookie отправляется только на адреса админского API (`/api/admin/…`), не на страницу бинго; +- срок — `ADMIN_SESSION_HOURS` (по умолчанию 8 часов) с момента входа, без продления. Когда закончится, + написано внизу страницы, после этого страница снова попросит ключ; +- кнопка «Выйти» завершает сессию на сервере сразу; +- сессии хранятся в памяти, поэтому перезапуск контейнера (`docker compose up -d` после правки `.env`, + обновление) завершает все входы; +- неверный ключ отвечает с задержкой полсекунды, чтобы подбор был бессмысленным. + +**Смена ключа:** новый `ADMIN_KEY` в `.env`, затем `docker compose up -d` — старые сессии тоже закончатся. + +Для скриптов можно не входить, а передавать ключ в заголовке: +`curl -H "Authorization: Bearer $ADMIN_KEY" https://bingo.example.com/api/admin/stats`. ## Нагрузка diff --git a/bingo/public/admin.html b/bingo/public/admin.html index c78568c..0c2a8c1 100644 --- a/bingo/public/admin.html +++ b/bingo/public/admin.html @@ -108,15 +108,16 @@ + @@ -176,14 +177,53 @@ } const weekTitle = (w) => (w.weekNo >= 1 ? `Неделя ${w.weekNo}` : 'До старта'); - let key = new URLSearchParams(location.hash.slice(1)).get('k') || ''; let data = null; - async function load() { - $('login-error').hidden = true; + function showLogin(message) { + data = null; + $('login').hidden = false; + $('content').hidden = true; + $('actions').hidden = true; + $('login-error').textContent = message || ''; + $('login-error').hidden = !message; + if (!message) $('key-input').focus(); + } + + async function postJson(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + credentials: 'same-origin', + cache: 'no-store', + }); + return { status: res.status, data: await res.json().catch(() => null) }; + } + + // Ключ обменивается на сессию (cookie ставит сервер); сам ключ нигде не сохраняется. + async function login(key) { + $('login-btn').disabled = true; try { - const res = await fetch('api/admin/stats', { headers: { Authorization: `Bearer ${key}` }, cache: 'no-store' }); - if (res.status === 403) throw new Error('Ключ не подошёл. Проверьте ссылку или задайте ADMIN_KEY заново.'); + const { status, data: body } = await postJson('api/admin/login', { key }); + if (status === 200) { $('key-input').value = ''; return load(); } + if (body?.error === 'admin_disabled') return showLogin('Админка выключена: на сервере не задан ADMIN_KEY.'); + if (status === 401) return showLogin('Ключ не подошёл. Проверьте его и попробуйте ещё раз.'); + return showLogin(`Сервер ответил ошибкой ${status}. Попробуйте через минуту.`); + } catch { + return showLogin('Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.'); + } finally { + $('login-btn').disabled = false; + } + } + + async function load(expiredNotice) { + try { + const res = await fetch('api/admin/stats', { credentials: 'same-origin', cache: 'no-store' }); + if (res.status === 401) { + const body = await res.json().catch(() => null); + if (body?.error === 'admin_disabled') return showLogin('Админка выключена: на сервере не задан ADMIN_KEY.'); + return showLogin(expiredNotice ? 'Вход закончился. Введите ключ ещё раз.' : ''); + } if (!res.ok) throw new Error(`Сервер ответил ошибкой ${res.status}. Попробуйте обновить через минуту.`); data = await res.json(); $('login').hidden = true; @@ -191,10 +231,7 @@ $('actions').hidden = false; render(); } catch (err) { - $('login').hidden = false; - $('content').hidden = true; - $('actions').hidden = true; - if (key) { $('login-error').textContent = err.message; $('login-error').hidden = false; } + showLogin(err.message.startsWith('Сервер') ? err.message : 'Нет связи с сервером. Попробуйте обновить через минуту.'); } } @@ -231,7 +268,10 @@ return `
${String(c.cell).padStart(2, '0')}${esc(cellName(c.cell))}
${c.total}
`; }).join('')}`).join(''); - $('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.`; + const until = data.session?.expiresAt + ? ` Вход действует до ${new Date(data.session.expiresAt).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}.` + : ''; + $('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.${until}`; } // CSV с разделителем «;» и BOM — открывается в русском Excel без мастера импорта. @@ -270,15 +310,22 @@ const b = e.target.closest('[data-csv]'); if (b && data) download(`balance-bingo-${b.dataset.csv}-${new Date().toISOString().slice(0, 10)}.csv`, exporters[b.dataset.csv]()); }); - $('reload').addEventListener('click', load); + $('reload').addEventListener('click', () => load(true)); + $('logout').addEventListener('click', async () => { + try { await postJson('api/admin/logout'); } catch { /* сессия всё равно истечёт сама */ } + showLogin(''); + }); $('login-form').addEventListener('submit', (e) => { e.preventDefault(); - key = $('key-input').value.trim(); - history.replaceState(null, '', `#k=${encodeURIComponent(key)}`); - load(); + const key = $('key-input').value.trim(); + if (key) login(key); }); - load(); + // Ссылка для входа /admin#k=КЛЮЧ: сразу убираем ключ из адреса и истории, затем входим. + const linkKey = new URLSearchParams(location.hash.slice(1)).get('k'); + if (location.hash) history.replaceState(null, '', location.pathname + location.search); + if (linkKey) login(linkKey); + else load(); })(); diff --git a/bingo/server/server.mjs b/bingo/server/server.mjs index 7599839..03fac86 100644 --- a/bingo/server/server.mjs +++ b/bingo/server/server.mjs @@ -5,7 +5,9 @@ // GET /api/me?d= → отметки и ответ опроса этого устройства за текущую неделю // POST /api/mark {d, week, cell, on} → отметить / снять клетку // POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?» -// GET /api/admin/stats → статистика (заголовок Authorization: Bearer ) +// POST /api/admin/login {key} → проверяет ключ, ставит cookie сессии (HttpOnly, SameSite=Strict) +// POST /api/admin/logout → завершает сессию +// GET /api/admin/stats → статистика (cookie сессии или заголовок Authorization: Bearer ) // GET /healthz → проверка живости для Docker // // Хранится только: случайный id устройства, неделя, номер клетки, ответ опроса. @@ -14,7 +16,7 @@ import http from 'node:http'; import { readFileSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'node:fs'; import { join, extname, relative, sep } from 'node:path'; -import { createHash, timingSafeEqual } from 'node:crypto'; +import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'; import { openDb, LINES_SQL, LINE_BONUS } from './db.mjs'; const ROOT = new URL('..', import.meta.url).pathname; @@ -39,6 +41,8 @@ const CFG = { goal: parseInt(process.env.GOAL || '500', 10), adminKey: readAdminKey(), backupKeep: parseInt(process.env.BACKUP_KEEP || '14', 10), + sessionHours: Number(process.env.ADMIN_SESSION_HOURS || '8'), + cookieSecure: !['0', 'false', 'no'].includes(String(process.env.COOKIE_SECURE || '').toLowerCase()), }; function fail(msg) { @@ -50,6 +54,7 @@ if (CFG.start > CFG.end) fail('SEASON_START позже SEASON_END.'); if (!Number.isInteger(CFG.goal) || CFG.goal < 1) fail('GOAL должен быть положительным целым числом.'); try { new Intl.DateTimeFormat('en-US', { timeZone: CFG.tz }); } catch { fail(`неизвестный часовой пояс TIMEZONE=${CFG.tz}.`); } if (CFG.adminKey && CFG.adminKey.length < 16) fail('ADMIN_KEY короче 16 символов.'); +if (!(CFG.sessionHours > 0 && CFG.sessionHours <= 720)) fail('ADMIN_SESSION_HOURS должен быть больше 0 и не больше 720.'); // ---------- база ---------- @@ -175,9 +180,30 @@ async function handleApi(req, res, url) { return json(res, 200, { ok: true, answer }); } + if (path === '/api/admin/login' && method === 'POST') { + if (!CFG.adminKey) return json(res, 403, { error: 'admin_disabled' }); + const body = await readJson(req); + if (body === TOO_LARGE) return tooLarge(res); + if (!body || !keyMatches(String(body.key || ''))) { + await new Promise((r) => setTimeout(r, 500)); // замедляем подбор + return json(res, 401, { error: 'bad_key' }); + } + const { token, expires } = createSession(); + res.setHeader('Set-Cookie', sessionCookie(token, CFG.sessionHours * 3600)); + return json(res, 200, { ok: true, expiresAt: new Date(expires).toISOString() }); + } + + if (path === '/api/admin/logout' && method === 'POST') { + const token = readCookie(req, COOKIE); + if (token) sessions.delete(tokenId(token)); + res.setHeader('Set-Cookie', sessionCookie('', 0)); + return json(res, 200, { ok: true }); + } + if (path === '/api/admin/stats' && method === 'GET') { - if (!isAdmin(req)) return json(res, 403, { error: 'forbidden' }); - return json(res, 200, adminStats()); + const auth = adminAuth(req); + if (!auth) return json(res, 401, { error: CFG.adminKey ? 'unauthorized' : 'admin_disabled' }); + return json(res, 200, { ...adminStats(), session: auth.expires ? { expiresAt: new Date(auth.expires).toISOString() } : null }); } return json(res, 404, { error: 'not_found' }); @@ -221,12 +247,58 @@ function adminStats() { }; } +// ---------- вход в админку ---------- +// +// Ключ вводится один раз (или приходит по ссылке /admin#k=…, страница сразу убирает его из адреса). +// Сервер выдаёт случайный токен в cookie: HttpOnly (недоступен скриптам), SameSite=Strict, Secure. +// Path не задаётся: браузер берёт путь запроса (/api/admin или /<папка>/api/admin за прокси), +// так что cookie уходит только на адреса админского API. +// Срок жизни фиксированный — ADMIN_SESSION_HOURS с момента входа, без продления. +// Сессии хранятся в памяти процесса (в виде хешей токенов): перезапуск контейнера завершает все сессии. + +const COOKIE = 'bingo_admin'; +const MAX_SESSIONS = 200; +const sessions = new Map(); // sha256(token) → время окончания, мс + const sha = (s) => createHash('sha256').update(s).digest(); -function isAdmin(req) { - if (!CFG.adminKey) return false; // ключ не задан — админка выключена +const tokenId = (token) => sha(token).toString('hex'); +const keyMatches = (given) => Boolean(CFG.adminKey) && timingSafeEqual(sha(given), sha(CFG.adminKey)); + +function createSession() { + const now = Date.now(); + for (const [id, exp] of sessions) if (exp <= now) sessions.delete(id); + while (sessions.size >= MAX_SESSIONS) sessions.delete(sessions.keys().next().value); // самая старая + const token = randomBytes(32).toString('base64url'); + const expires = now + CFG.sessionHours * 3600 * 1000; + sessions.set(tokenId(token), expires); + return { token, expires }; +} + +function sessionCookie(token, maxAgeSec) { + return `${COOKIE}=${token}; Max-Age=${Math.floor(maxAgeSec)}; HttpOnly; SameSite=Strict${CFG.cookieSecure ? '; Secure' : ''}`; +} + +function readCookie(req, name) { + for (const part of (req.headers.cookie || '').split(';')) { + const i = part.indexOf('='); + if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim(); + } + return ''; +} + +// Возвращает { expires } для cookie-сессии, { expires: null } для Bearer-ключа (скрипты, curl) или null. +function adminAuth(req) { + if (!CFG.adminKey) return null; // ключ не задан — админка выключена + const token = readCookie(req, COOKIE); + if (token) { + const id = tokenId(token); + const exp = sessions.get(id); + if (exp && exp > Date.now()) return { expires: exp }; + sessions.delete(id); + } const auth = req.headers.authorization || ''; - const given = auth.startsWith('Bearer ') ? auth.slice(7) : ''; - return timingSafeEqual(sha(given), sha(CFG.adminKey)); + if (auth.startsWith('Bearer ') && keyMatches(auth.slice(7))) return { expires: null }; + return null; } // ---------- статика ---------- @@ -408,6 +480,7 @@ server.listen(CFG.port, () => { const s = season(); console.log(`Бинго «Баланс 2.0» слушает порт ${CFG.port}. Сезон ${CFG.start} — ${CFG.end} (${CFG.tz}), цель ${CFG.goal}, сейчас: ${s.phase}.`); if (!CFG.adminKey) console.log('ADMIN_KEY не задан — страница /admin выключена.'); + else console.log(`Вход в /admin действует ${CFG.sessionHours} ч.${CFG.cookieSecure ? '' : ' Внимание: COOKIE_SECURE выключен.'}`); }); function shutdown() { diff --git a/bingo/tests/admin-session.test.mjs b/bingo/tests/admin-session.test.mjs new file mode 100644 index 0000000..9058807 --- /dev/null +++ b/bingo/tests/admin-session.test.mjs @@ -0,0 +1,44 @@ +// Session expiry and the "admin disabled" case, each on its own server instance. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer, ADMIN_KEY } from './helpers.mjs'; + +const post = (base, path, body, headers = {}) => fetch(base + path, { + method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), +}); + +test('session expires after ADMIN_SESSION_HOURS', async () => { + const srv = await startServer({ ADMIN_SESSION_HOURS: String(1.5 / 3600) }); // 1.5 seconds + try { + const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY }); + assert.equal(res.status, 200); + const cookie = res.headers.get('set-cookie').split(';')[0]; + assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 200); + await new Promise((r) => setTimeout(r, 1800)); + assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 401); + } finally { + srv.stop(); + } +}); + +test('without ADMIN_KEY the admin API is disabled', async () => { + const srv = await startServer({ ADMIN_KEY: '' }); + try { + const res = await post(srv.base, '/api/admin/login', { key: 'anything-anything-anything' }); + assert.equal(res.status, 403); + assert.equal((await res.json()).error, 'admin_disabled'); + assert.equal((await (await fetch(srv.base + '/api/admin/stats')).json()).error, 'admin_disabled'); + } finally { + srv.stop(); + } +}); + +test('COOKIE_SECURE=0 drops the Secure flag (plain-http testing only)', async () => { + const srv = await startServer({ COOKIE_SECURE: '0' }); + try { + const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY }); + assert.doesNotMatch(res.headers.get('set-cookie'), /Secure/); + } finally { + srv.stop(); + } +}); diff --git a/bingo/tests/api.test.mjs b/bingo/tests/api.test.mjs index a504dc4..21a3f43 100644 --- a/bingo/tests/api.test.mjs +++ b/bingo/tests/api.test.mjs @@ -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 //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); diff --git a/bingo/tests/helpers.mjs b/bingo/tests/helpers.mjs new file mode 100644 index 0000000..f606846 --- /dev/null +++ b/bingo/tests/helpers.mjs @@ -0,0 +1,33 @@ +// Starts the real server on a free port with a throwaway database. +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'; + +export const ADMIN_KEY = 'test-admin-key-1234567890'; + +const freePort = () => new Promise((resolve) => { + const s = createServer().listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); +}); + +export async function startServer(env = {}) { + const dataDir = mkdtempSync(join(tmpdir(), 'bingo-test-')); + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const 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', ...env, + }, + 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)); + } + return { + base, + stop() { proc.kill(); rmSync(dataDir, { recursive: true, force: true }); }, + }; +}