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:
Claude authored and Olga Bregar Dunaeva committed 2026-10-30 20:00:21 +00:00
1 parent b6f0791243
commit 9b421c9b47
8 files changed
+320 -66

No files matched your search

+26 -4
View File
@@ -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=<id>` | — | `{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 <ADMIN_KEY>` | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts |
| `POST /api/admin/login` | `{key}` | 200 + `Set-Cookie: bingo_admin=<token>`; 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 <ADMIN_KEY>` 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
`/<prefix>/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 <ADMIN_KEY>` 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 <domain>`), 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://<domain>/`; organisers → `https://<domain>/admin#k=<ADMIN_KEY>`.
5. Links: team → `https://<domain>/`; organisers → `https://<domain>/admin` (log in with the key;
`/admin#k=<ADMIN_KEY>` 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