From 9016ce02e40e65d9ce19256505700b85d0e389f3 Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 24 Sep 2026 22:14:58 +0200 Subject: [PATCH] feat(auth): add optional admin PIN for all API writes Off by default. Once a PIN is set in Settings -> System & backup, every non-GET /api/* request (plus GET /api/ammc/config) needs a signed 30-day HttpOnly cookie, which also closes the unauthenticated /api/update/run restart trigger. A 401 opens a PIN dialog and the pending save is retried after login. Reads stay open because the public overlay hydrates through GET /api/state. Optional RC_ADMIN_ALLOWED_CIDRS restricts the same routes by client address. The PIN hash and signing secret live in data/, outside the app_state blob, so they never leak through GET /api/state, exports or backups. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + README.md | 2 +- README.sv.md | 2 +- ROADMAP.md | 13 +- docs/javascript-architecture.md | 20 +++ docs/security-performance-audit.md | 12 ++ package-lock.json | 4 +- package.json | 2 +- server.js | 47 ++++++ server/auth.js | 155 +++++++++++++++++++ src/app.js | 58 +++++++ src/auth_view.js | 109 +++++++++++++ src/misc_views.js | 2 +- src/settings.js | 7 + src/styles.css | 12 ++ tests/auth.test.js | 92 +++++++++++ tests/browser_auth.js | 45 ++++++ tests/browser_smoke.js | 2 + tests/run.js | 1 + tests/server_persistence.integration.test.js | 43 +++++ 20 files changed, 627 insertions(+), 8 deletions(-) create mode 100644 server/auth.js create mode 100644 src/auth_view.js create mode 100644 tests/auth.test.js create mode 100644 tests/browser_auth.js diff --git a/CHANGELOG.md b/CHANGELOG.md index dec7912..8f6a62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.22.0 - 2026-09-24 + +### Added + +- Optional admin PIN (Settings -> System & backup). Off by default; once set, every change through the API - including starting an update/restart - needs the PIN, entered once per browser and remembered for 30 days. A login dialog appears automatically when a change is blocked. Reading data and public overlay/training links are unaffected. +- Optional `RC_ADMIN_ALLOWED_CIDRS` environment variable to restrict changes to specific IP addresses/subnets. + ## 1.21.2 - 2026-09-12 ### Changed diff --git a/README.md b/README.md index a0e9d97..7bd2b16 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ If you want simple protection for public overlays, set this environment variable PUBLIC_OVERLAY_TOKEN=your-secret-token ``` -This token gates the overlay route only. Do not expose port `8081` directly to the internet because administration APIs are not authenticated. Use a trusted race LAN, VPN, or authenticated reverse proxy. If the UI is intentionally served from another origin, list it in `RC_ALLOWED_ORIGINS` (comma-separated). See [`docs/security-performance-audit.md`](docs/security-performance-audit.md). +This token gates the overlay route only. Changes through the admin API can be protected with an optional admin PIN (Settings -> System & backup; off by default) and restricted to specific addresses with `RC_ADMIN_ALLOWED_CIDRS` (comma-separated IPv4 CIDRs or exact IPs, e.g. `192.168.1.0/24`). Reads are not PIN protected, so still do not expose port `8081` directly to the internet. Use a trusted race LAN, VPN, or authenticated reverse proxy. If the UI is intentionally served from another origin, list it in `RC_ALLOWED_ORIGINS` (comma-separated). See [`docs/security-performance-audit.md`](docs/security-performance-audit.md). Then the public overlay URL must include: diff --git a/README.sv.md b/README.sv.md index 7d20191..8941e46 100644 --- a/README.sv.md +++ b/README.sv.md @@ -118,7 +118,7 @@ Om du vill ha ett enkelt skydd för publika overlays, sätt denna miljövariabel PUBLIC_OVERLAY_TOKEN=din-hemliga-token ``` -Tokenen skyddar bara overlay-routen. Exponera inte port `8081` direkt mot internet eftersom administrations-API:erna saknar autentisering. Använd betrott race-LAN, VPN eller en autentiserad reverse proxy. Om webbgränssnittet avsiktligt körs från en annan origin anges den i `RC_ALLOWED_ORIGINS` (kommaseparerat). Se [`docs/security-performance-audit.md`](docs/security-performance-audit.md). +Tokenen skyddar bara overlay-routen. Ändringar via admin-API:t kan skyddas med en valfri admin-PIN (Inställningar -> System & backup; av som standard) och begränsas till vissa adresser med `RC_ADMIN_ALLOWED_CIDRS` (kommaseparerade IPv4-CIDR eller exakta IP:n, t.ex. `192.168.1.0/24`). Läsning är inte PIN-skyddad, så exponera fortfarande inte port `8081` direkt mot internet. Använd betrott race-LAN, VPN eller en autentiserad reverse proxy. Om webbgränssnittet avsiktligt körs från en annan origin anges den i `RC_ALLOWED_ORIGINS` (kommaseparerat). Se [`docs/security-performance-audit.md`](docs/security-performance-audit.md). Då måste den publika overlay-länken innehålla: diff --git a/ROADMAP.md b/ROADMAP.md index 36767eb..f8b4268 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,10 +164,19 @@ The user framed the actual threat model clearly: this normally runs on a trusted - **2FA: explicitly not part of this.** Only relevant if a club deliberately exposes the server past their own LAN (port-forwarded for a remote stream day, say) - a real but separate scenario from "someone else at the track." If that ever comes up, the natural shape is requiring a TOTP code on top of the PIN specifically for requests arriving from outside the allowlisted ranges, not a blanket requirement that would slow down the normal trackside case for no benefit. Not planned unless a club actually asks for internet-facing access. - **Server-side enforcement on every mutating endpoint**, not just hiding buttons (this was already the plan below) - a small role-to-endpoint capability map, in the same spirit as the `MANAGE_TAB_TARGETS`-style lookup tables already used elsewhere in this codebase rather than scattering checks ad hoc through `server.js`. - **Public overlays and training links are unaffected** - they're already gated per-share-token independently of admin auth (see the security audit's scope notes) and are meant to be shared outside the operator group; nothing about this design changes that. -- **Open question needing the user's decision before building:** auth off by default (today's zero-friction behavior, but does nothing until someone remembers to turn it on) vs. on by default with an auto-generated Admin PIN printed once to the console/log on first run (safer out of the box, closer to how other self-hosted tools like this one already print startup info to logs, but a new "one more thing to notice" step during setup). Leaning toward on-by-default given the whole point is closing a real live-race risk, but this is exactly the kind of call that should be confirmed, not assumed, before implementation starts. +- **Decided 2026-09-24 by the user:** off by default (today's zero-friction behavior), and a single Admin PIN first - per-role PINs come later. + +### Implemented 2026-09-24: v1 - one optional Admin PIN + +- [x] `server/auth.js` (new): PIN hashed with scrypt (salted) and stored in `data/auth.json`, deliberately outside the `app_state` blob so it can never leak through `GET /api/state`, an export or a backup; an HMAC-signed, `HttpOnly`/`SameSite=Strict` cookie valid 30 days, signed with a per-installation secret in `data/auth_secret` - survives server restarts (the updater and power blips restart the process mid-event), and changing the PIN invalidates every earlier cookie. Failed logins are throttled (10 per 5 minutes per client address). +- [x] Middleware in `server.js`, registered right after the CORS middleware: once a PIN is set, every non-GET `/api/*` request plus `GET /api/ammc/config` (it exposes the AMMC executable path) needs the cookie - including `POST /api/update/run`, which closes the unauthenticated restart trigger from the 2026-09-12 audit follow-up. Other reads stay open on purpose: the public overlay hydrates through `GET /api/state`, so closing reads needs the sanitized public-overlay API first. Public overlay/training routes are unaffected. New routes: `GET /api/auth/status`, `POST /api/auth/login`, `/logout`, `/pin`, `/disable`. +- [x] Optional `RC_ADMIN_ALLOWED_CIDRS` (IPv4 CIDRs and/or exact addresses, comma-separated) applies to the same protected routes independently of the PIN - it works even with no PIN set. +- [x] Frontend: `src/auth_view.js` (new) wraps `fetch` once at startup so any `401 auth_required` opens a PIN dialog; after a correct PIN the pending save is retried automatically. Settings -> System & backup has a new "Admin PIN" panel to turn protection on, change the PIN, log out, or turn it off. + +Verified 2026-09-24: `tests/auth.test.js` (hashing, token expiry/tampering/PIN-change invalidation, protected vs open routes, throttling, CIDR matching) - it caught a real flaw while being written: `Buffer.from(hex)` silently drops a trailing odd character, so a signature with junk appended was accepted until `verifyToken` got a strict format check. An integration test against a real server confirms writes are open by default, rejected without the cookie once a PIN is set (including `/api/update/run` and changing the PIN), reads stay open, and the PIN hash never appears in `GET /api/state`. `tests/browser_auth.js` drives the real UI: enable PIN, log out, a normal edit opens the dialog, a wrong PIN is rejected, the right one lets the pending edit through, then turns protection off again. 212 unit/integration tests and 39 browser scenarios pass. Not covered yet: cross-origin UIs via `RC_ALLOWED_ORIGINS` (the cookie is same-origin only), and per-role PINs. - [ ] Define Admin, Race Director, Speaker and Read-only capabilities. -- [ ] Enforce permissions on server endpoints, not only in the UI. +- [x] Enforce permissions on server endpoints, not only in the UI (v1: one Admin PIN for all writes - per-role enforcement still to do). - [ ] Keep public overlays and training links restricted. - [ ] Build compact mobile controls for start/stop, track call, flags and next heat. - [ ] Test concurrent control and accidental repeated commands. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 7f2ddd6..5567894 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -219,6 +219,17 @@ Uses: - `logs/server.out.log` - `logs/server.err.log` +### `server/auth.js` +Role: +- optional admin PIN protection (CommonJS, required by `server.js`) + +Owns: +- PIN hashing/verification (scrypt) in `data/auth.json` and the per-installation HMAC secret in `data/auth_secret` - both outside `app_state` on purpose +- signed 30-day login cookie (`rc_auth`), invalidated when the PIN changes +- login throttling per client address +- `middleware`: once a PIN exists, rejects unauthenticated non-GET `/api/*` requests and `GET /api/ammc/config` with `401 { error: "auth_required" }`; applies `RC_ADMIN_ALLOWED_CIDRS` (403 `ip_not_allowed`) to the same routes regardless of PIN +- routes wired in `server.js`: `GET /api/auth/status`, `POST /api/auth/login`, `/logout`, `/pin`, `/disable` + ### `scripts/record-video-guides.mjs` and `scripts/capture-screenshots.mjs` Role: - documentation tooling - not run in production, not imported by `server.js` or any `src/*.js` module @@ -531,6 +542,15 @@ Imported by: - `src/settings.js` for the "Banlayouter" panel (add/list/delete, delete blocked while any session still references the layout) - `src/results_archive_view.js` to show which layout a completed session ran on +### `src/auth_view.js` +Type: +- admin PIN UI + +Owns: +- `installAuthFetchGuard`: wraps `window.fetch` once at startup (`app.js`) so a `401 auth_required` from `/api/*` opens the PIN dialog; `app.js` retries the pending sync after a successful login +- `showPinDialog`: the login modal +- `mountAuthSettings`: the "Admin PIN" panel in Settings -> System & backup (enable, change, log out, disable) + ### `src/misc_views.js` Type: - misc page renderers diff --git a/docs/security-performance-audit.md b/docs/security-performance-audit.md index 915470d..39c1a1e 100644 --- a/docs/security-performance-audit.md +++ b/docs/security-performance-audit.md @@ -163,3 +163,15 @@ No other new route introduces a new class of risk; each falls under a finding al ### New dev-only tooling - no production exposure `scripts/record-video-guides.mjs` and `scripts/capture-screenshots.mjs` (this session's video-guide/screenshot generators) are not imported by `server.js` or any `src/*.js` module, are never started by any HTTP route, and are meant to be run manually by a developer against an isolated temp server/SQLite instance - they have no network-reachable attack surface and were excluded from further review on that basis beyond the injection-safety check above. + +## Follow-up - 2026-09-24: optional admin PIN + +The "Critical: administration API has no authentication" finding and the 2026-09-12 `/api/update/run` finding are now **mitigable, but not closed by default**: an optional admin PIN (`server/auth.js`, off until set in Settings -> System & backup) protects every non-GET `/api/*` route plus `GET /api/ammc/config` - which covers state replacement, AMMC config/start/stop (the executable-path risk), passings, PDF export, and the update/restart trigger. `RC_ADMIN_ALLOWED_CIDRS` can additionally restrict those routes by client address, with or without a PIN. + +Deliberately still open, so the original findings remain valid for an installation that hasn't set a PIN, and partly even for one that has: + +- Reads (`GET /api/state`, `GET /api/passings`, backups listing) stay unauthenticated because the public overlay hydrates through `GET /api/state` - the "High: public overlay token does not protect the state API" finding is unchanged until a sanitized public-overlay endpoint exists. +- No rate limiting beyond the login throttle (10 failed PINs per 5 minutes per address). +- A cross-origin admin UI (`RC_ALLOWED_ORIGINS`) can't use the cookie, which is same-origin (`SameSite=Strict`) by design. + +Design notes: the PIN hash (scrypt, salted) lives in `data/auth.json`, never in the `app_state` blob, so it can't leak via `GET /api/state`, a JSON export or a backup. The login cookie is `HttpOnly`, `SameSite=Strict`, HMAC-signed with a per-installation secret (`data/auth_secret`), valid 30 days, and invalidated by any PIN change. Token signatures are format-checked strictly, because `Buffer.from(hex)` silently drops a trailing odd character (caught by the unit tests while this was built). diff --git a/package-lock.json b/package-lock.json index d8a693c..ab67b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.21.2", + "version": "1.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.21.2", + "version": "1.22.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 7400e99..61dcdf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.21.2", + "version": "1.22.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server.js b/server.js index f14c4eb..b3d3ca0 100644 --- a/server.js +++ b/server.js @@ -8,12 +8,14 @@ const QRCode = require("qrcode"); const Database = require("better-sqlite3"); const publicTrainingProfile = require("./server/public_training_profile"); const { createBackupService } = require("./server/backup_service"); +const { createAuth, COOKIE_NAME: AUTH_COOKIE_NAME, TOKEN_MAX_AGE_MS } = require("./server/auth"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMING_DATA_DIR) : path.join(__dirname, "data"); const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite"); const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json"); +const auth = createAuth({ dataDir: DB_DIR, allowedCidrs: process.env.RC_ADMIN_ALLOWED_CIDRS || "" }); const PID_FILE = process.env.RC_TIMING_PID_FILE || ""; const SRC_DIR = path.join(__dirname, "src"); const RACE_SOUND_DIR = path.join(__dirname, "rc-race-sound-pack"); @@ -152,6 +154,51 @@ app.use((req, res, next) => { next(); }); +app.use(auth.middleware); + +function setAuthCookie(res, token) { + const maxAge = token ? Math.floor(TOKEN_MAX_AGE_MS / 1000) : 0; + res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${token || ""}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`); +} + +app.get("/api/auth/status", (req, res) => { + res.json({ enabled: auth.isEnabled(), authenticated: auth.isEnabled() ? auth.isAuthenticated(req) : true, ipAllowed: auth.isIpAllowed(req.ip) }); +}); + +app.post("/api/auth/login", (req, res) => { + if (auth.isLoginBlocked(req.ip)) return res.status(429).json({ error: "too_many_attempts" }); + if (!auth.isEnabled()) return res.json({ ok: true, enabled: false }); + if (!auth.verifyPin(req.body?.pin)) { + auth.registerLoginFailure(req.ip); + return res.status(401).json({ error: "wrong_pin" }); + } + setAuthCookie(res, auth.issueToken()); + return res.json({ ok: true, enabled: true }); +}); + +app.post("/api/auth/logout", (_req, res) => { + setAuthCookie(res, ""); + res.json({ ok: true }); +}); + +// Protected by the middleware once a PIN exists; while auth is off, setting the first PIN +// is open, which matches how every other admin setting behaves in that state. +app.post("/api/auth/pin", (req, res) => { + try { + auth.setPin(req.body?.newPin); + } catch (error) { + return res.status(400).json({ error: error.message }); + } + setAuthCookie(res, auth.issueToken()); + return res.json({ ok: true, enabled: true }); +}); + +app.post("/api/auth/disable", (_req, res) => { + auth.disable(); + setAuthCookie(res, ""); + res.json({ ok: true, enabled: false }); +}); + app.get("/api/health", (_req, res) => { res.json({ ok: true, dbPath: DB_PATH }); }); diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..d38452f --- /dev/null +++ b/server/auth.js @@ -0,0 +1,155 @@ +// Optional admin PIN protection for mutating API routes. Off until a PIN is set. +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const COOKIE_NAME = "rc_auth"; +const TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +const MIN_PIN_LENGTH = 4; +const LOGIN_WINDOW_MS = 5 * 60 * 1000; +const LOGIN_MAX_FAILURES = 10; + +function hashPin(pin, salt) { + return crypto.scryptSync(String(pin), salt, 32).toString("hex"); +} + +function parseCookies(header) { + const cookies = {}; + String(header || "").split(";").forEach((part) => { + const index = part.indexOf("="); + if (index > 0) cookies[part.slice(0, index).trim()] = decodeURIComponent(part.slice(index + 1).trim()); + }); + return cookies; +} + +function normalizeIp(ip) { + const value = String(ip || "").trim(); + return value.startsWith("::ffff:") ? value.slice(7) : value; +} + +function ipv4ToInt(ip) { + const parts = ip.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +// Comma-separated IPv4 CIDRs or exact addresses (IPv4 or IPv6). Empty = allow everyone. +function parseAllowedRanges(value) { + return String(value || "").split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => { + const [base, bits] = entry.split("/"); + const baseInt = ipv4ToInt(base); + if (baseInt === null || bits === undefined) return { exact: normalizeIp(entry) }; + const prefix = Math.max(0, Math.min(32, Number(bits))); + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return { network: (baseInt & mask) >>> 0, mask }; + }); +} + +function isIpAllowed(ip, ranges) { + if (!ranges.length) return true; + const normalized = normalizeIp(ip); + const asInt = ipv4ToInt(normalized); + return ranges.some((range) => (range.exact !== undefined + ? range.exact === normalized + : asInt !== null && ((asInt & range.mask) >>> 0) === range.network)); +} + +function createAuth({ dataDir, allowedCidrs = "", now = () => Date.now() }) { + // Kept out of the app_state blob on purpose, so a PIN hash can never leak through + // GET /api/state, a JSON export or a backup restore. + const configPath = path.join(dataDir, "auth.json"); + const secretPath = path.join(dataDir, "auth_secret"); + const ranges = parseAllowedRanges(allowedCidrs); + const loginFailures = new Map(); + + function readConfig() { + try { return JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { return null; } + } + function writeConfig(config) { + fs.mkdirSync(dataDir, { recursive: true }); + if (config) fs.writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 }); + else fs.rmSync(configPath, { force: true }); + } + function getSecret() { + try { return fs.readFileSync(secretPath, "utf8").trim(); } catch {} + const secret = crypto.randomBytes(32).toString("hex"); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(secretPath, secret, { mode: 0o600 }); + return secret; + } + function sign(payload) { + return crypto.createHmac("sha256", getSecret()).update(payload).digest("hex"); + } + + function isEnabled() { + return Boolean(readConfig()?.pinHash); + } + function verifyPin(pin) { + const config = readConfig(); + if (!config?.pinHash) return false; + const expected = Buffer.from(config.pinHash, "hex"); + const actual = Buffer.from(hashPin(pin, config.salt), "hex"); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); + } + function setPin(newPin) { + const pin = String(newPin || ""); + if (pin.length < MIN_PIN_LENGTH) throw new Error(`PIN must be at least ${MIN_PIN_LENGTH} characters`); + const salt = crypto.randomBytes(16).toString("hex"); + // A fresh pinVersion invalidates every token issued under the previous PIN. + writeConfig({ salt, pinHash: hashPin(pin, salt), pinVersion: crypto.randomBytes(8).toString("hex"), updatedAt: new Date(now()).toISOString() }); + } + function disable() { + writeConfig(null); + } + + function issueToken() { + const issuedAt = String(now()); + return `${issuedAt}.${sign(`${issuedAt}.${readConfig()?.pinVersion || ""}`)}`; + } + function verifyToken(token) { + const config = readConfig(); + const [issuedAt, signature] = String(token || "").split("."); + // Strict format check: Buffer.from(hex) silently drops a trailing odd character. + if (!config?.pinVersion || !/^\d+$/.test(issuedAt || "") || !/^[0-9a-f]{64}$/.test(signature || "")) return false; + if (now() - Number(issuedAt) > TOKEN_MAX_AGE_MS) return false; + const expected = Buffer.from(sign(`${issuedAt}.${config.pinVersion}`), "hex"); + const actual = Buffer.from(signature, "hex"); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); + } + function isAuthenticated(req) { + return verifyToken(parseCookies(req.headers?.cookie)[COOKIE_NAME]); + } + + // PINs are short, so failed logins are throttled per client address. + function recentFailures(ip) { + const key = normalizeIp(ip); + const list = (loginFailures.get(key) || []).filter((at) => now() - at < LOGIN_WINDOW_MS); + loginFailures.set(key, list); + return list; + } + function registerLoginFailure(ip) { + recentFailures(ip).push(now()); + } + function isLoginBlocked(ip) { + return recentFailures(ip).length >= LOGIN_MAX_FAILURES; + } + + // Writes plus the AMMC config read (it exposes the executable path) are protected; + // other reads stay open because the public overlay hydrates through GET /api/state. + function isProtectedRoute(req) { + const route = String(req.path || ""); + if (!route.startsWith("/api/") || route === "/api/auth/login" || route === "/api/auth/status") return false; + if (!["GET", "HEAD", "OPTIONS"].includes(req.method)) return true; + return route === "/api/ammc/config"; + } + function middleware(req, res, next) { + if (!isProtectedRoute(req)) return next(); + if (!isIpAllowed(req.ip, ranges)) return res.status(403).json({ error: "ip_not_allowed" }); + if (isEnabled() && !isAuthenticated(req)) return res.status(401).json({ error: "auth_required" }); + return next(); + } + + return { isEnabled, verifyPin, setPin, disable, issueToken, verifyToken, isAuthenticated, registerLoginFailure, isLoginBlocked, isProtectedRoute, middleware, isIpAllowed: (ip) => isIpAllowed(ip, ranges) }; +} + +module.exports = { createAuth, parseCookies, isIpAllowed, parseAllowedRanges, COOKIE_NAME, TOKEN_MAX_AGE_MS, MIN_PIN_LENGTH }; diff --git a/src/app.js b/src/app.js index 1b446b1..5a4e668 100644 --- a/src/app.js +++ b/src/app.js @@ -19,6 +19,7 @@ import { normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTra import { renderDashboardView, renderClassesView, renderDriversView, renderCarsView } from "./core_views.js"; import { renderGuideView, renderOverlayPageView } from "./misc_views.js"; +import { installAuthFetchGuard, showPinDialog, mountAuthSettings } from "./auth_view.js"; import { getSessionsForEventHelper, getModeLabelHelper, normalizeStartModeHelper, getStartModeLabelHelper, getClassNameHelper, getEventNameHelper, renderAssignmentListView, renderSessionsTableView } from "./event_common.js"; import { renderEventWorkspaceView } from "./event_workspace_controller.js"; import { renderEventManagerMarkup } from "./event_manager_view.js"; @@ -1063,6 +1064,28 @@ const TRANSLATIONS = { "guide.tab_run": "Köra ett race", "guide.tab_training": "Träning & resultat", "guide.tab_videos": "Videoguider", + "auth.title": "Admin-PIN", + "auth.hint": "Skyddar alla ändringar (race, resultat, inställningar, uppdatering) med en PIN. Läsning och publika overlay-/träningslänkar påverkas inte. Inloggningen sparas i webbläsaren i 30 dagar.", + "auth.state_off": "Av - vem som helst på nätverket kan ändra.", + "auth.state_on_logged_in": "På - du är inloggad i den här webbläsaren.", + "auth.state_on_logged_out": "På - du är inte inloggad i den här webbläsaren.", + "auth.new_pin": "Ny PIN (minst 4 tecken)", + "auth.new_pin_change": "Byt till ny PIN", + "auth.confirm_pin": "Upprepa PIN", + "auth.enable": "Slå på PIN-skydd", + "auth.change_pin": "Byt PIN", + "auth.logout": "Logga ut", + "auth.disable": "Stäng av PIN-skydd", + "auth.disable_confirm": "Stänga av PIN-skyddet? Då kan alla på nätverket ändra igen.", + "auth.pin_mismatch": "PIN-koderna matchar inte.", + "auth.login_required": "Logga in med nuvarande PIN först.", + "auth.status_error": "Kunde inte läsa PIN-status från servern.", + "auth.login_title": "Logga in", + "auth.login_hint": "Ändringar är PIN-skyddade. Ange admin-PIN för att fortsätta.", + "auth.login": "Logga in", + "auth.cancel": "Avbryt", + "auth.wrong_pin": "Fel PIN.", + "auth.too_many": "För många felaktiga försök. Vänta några minuter.", "guide.schedule_1": "1. Öppna Hantera -> fliken Schema & utskrift för ett race-event. Sätt starttid och paus mellan sessioner (standard 3 minuter) och klicka Spara schema.", "guide.schedule_2": "2. Varje sessions planerade klockslag räknas ut i körordning (practice, sedan kval, sedan finaler) utifrån starttiden, sessionens längd/efterlöp och pausen mellan sessioner.", "guide.schedule_3": "3. Så fort en session faktiskt startar eller avslutas flyttas resten av schemat automatiskt fram till den verkliga tiden - en sen start eller en session som drar över (inklusive paus) skjuter resten av dagen framåt utan att du behöver räkna om manuellt. En session som startade mer än en minut sent märks \"Försenad\".", @@ -1803,6 +1826,7 @@ const TRANSLATIONS = { "guide.race_day_6": "6. Använd Race Day för start, paus, fortsätt och avslut. Menyn visar server- och sparstatus separat från decodern. Vid sparfel: behåll fliken öppen och klicka Försök igen; väntande ändringar är inte bekräftat sparade på servern. Tidtagning, Domare och Overlay finns som direkta genvägar. Lokal restore kräver stoppad server och npm run backup:restore -- --confirm.", "guide.race_day_7": "7. Session replay i Tidtagning spelar upp sparade passeringar i ett isolerat testläge. Välj session, ladda och kör i 1x, 2x, 5x eller 10x utan att ändra originalresultat eller träningsstatistik.", "guide.race_day_8": "8. Redan när du registrerar eller ändrar en förare under Förare varnar systemet om transpondern redan används av en annan förare (t.ex. i fel klass) och namnger vem - ett komplement till förkontrollens kontroll av dubbla transpondrar inom samma heat. Samma person som kör flera klasser med sin egen transponder flaggas aldrig.", + "guide.race_day_9": "9. Kör ni på ett nät där fler har åtkomst (t.ex. arenans WiFi)? Slå på Admin-PIN under Inställningar -> System & backup. Då kräver alla ändringar PIN - resultat, inställningar och uppdatering - medan visning och publika overlay-/träningslänkar fungerar som vanligt. Varje webbläsare loggar in en gång och kommer ihåg det i 30 dagar.", "guide.dashboard_title": "Schemaavvikelse på Översikt", "guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.", "guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.", @@ -2621,6 +2645,28 @@ const TRANSLATIONS = { "guide.tab_run": "Run a race", "guide.tab_training": "Training & results", "guide.tab_videos": "Video guides", + "auth.title": "Admin PIN", + "auth.hint": "Protects every change (races, results, settings, updates) with a PIN. Reading and public overlay/training links are unaffected. The login is remembered in this browser for 30 days.", + "auth.state_off": "Off - anyone on the network can make changes.", + "auth.state_on_logged_in": "On - you are logged in in this browser.", + "auth.state_on_logged_out": "On - you are not logged in in this browser.", + "auth.new_pin": "New PIN (at least 4 characters)", + "auth.new_pin_change": "Change to new PIN", + "auth.confirm_pin": "Repeat PIN", + "auth.enable": "Turn on PIN protection", + "auth.change_pin": "Change PIN", + "auth.logout": "Log out", + "auth.disable": "Turn off PIN protection", + "auth.disable_confirm": "Turn off PIN protection? Anyone on the network will be able to make changes again.", + "auth.pin_mismatch": "The PINs do not match.", + "auth.login_required": "Log in with the current PIN first.", + "auth.status_error": "Could not read PIN status from the server.", + "auth.login_title": "Log in", + "auth.login_hint": "Changes are PIN protected. Enter the admin PIN to continue.", + "auth.login": "Log in", + "auth.cancel": "Cancel", + "auth.wrong_pin": "Wrong PIN.", + "auth.too_many": "Too many failed attempts. Wait a few minutes.", "guide.schedule_1": "1. Open Manage -> the Schedule & print tab for a race event. Set a start time and the gap between sessions (default 3 minutes) and click Save schedule.", "guide.schedule_2": "2. Every session's planned clock time is worked out in running order (practice, then qualifying, then finals) from the start time, each session's own duration/follow-up, and the gap between sessions.", "guide.schedule_3": "3. As soon as a session actually starts or finishes, the rest of the schedule automatically shifts to match reality - a late start or a session that runs long (including any pause) pushes the rest of the day forward with no manual recalculation. A session that started more than a minute late is marked \"Delayed\".", @@ -3361,6 +3407,7 @@ const TRANSLATIONS = { "guide.race_day_6": "6. Use Race Day for start, pause, resume, and finish. The menu shows server/save status separately from the decoder. On save failure, keep the tab open and click Retry; pending changes are not confirmed saved on the server. Timing, Judging, and Overlay remain one click away. Local restore requires a stopped server and npm run backup:restore -- --confirm.", "guide.race_day_7": "7. Session replay in Timing replays saved passings in an isolated test mode. Select a session, load it, and run at 1x, 2x, 5x, or 10x without modifying source results or training statistics.", "guide.race_day_8": "8. Already when you register or edit a driver under Drivers, the system warns if the transponder is already used by a different driver (e.g. in the wrong class) and names who - a complement to the preflight's duplicate-transponder check within a single heat. The same person racing multiple classes on their own transponder is never flagged.", + "guide.race_day_9": "9. Running on a network others can reach (e.g. the venue WiFi)? Turn on the Admin PIN under Settings -> System & backup. Every change then needs the PIN - results, settings and updates - while viewing and public overlay/training links work as usual. Each browser logs in once and is remembered for 30 days.", "guide.dashboard_title": "Schedule drift on overview", "guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.", "guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.", @@ -3793,6 +3840,17 @@ async function init() { if (currentView === "settings") renderViewFromBackground(); }); } + installAuthFetchGuard({ + onAuthRequired: () => showPinDialog({ + t, + escapeHtml, + onSuccess: async () => { + if (localStateDirty || backend.writeError) await syncStateToBackend(); + updateSyncStatus(); + mountAuthSettings(document.getElementById("authSettings"), { t, escapeHtml }); + }, + }), + }); seedDefaultData(); applyTheme(); renderNav(); diff --git a/src/auth_view.js b/src/auth_view.js new file mode 100644 index 0000000..b19bd2a --- /dev/null +++ b/src/auth_view.js @@ -0,0 +1,109 @@ +// Admin PIN UI: a login dialog shown whenever the backend answers 401 auth_required, +// and the PIN panel under Settings -> System & backup. +let dialogOpen = false; + +export function installAuthFetchGuard({ onAuthRequired }) { + const originalFetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const response = await originalFetch(input, init); + if (response.status === 401) { + const path = new URL(typeof input === "string" ? input : input.url, window.location.href).pathname; + if (path.startsWith("/api/") && !path.startsWith("/api/auth/")) { + const body = await response.clone().json().catch(() => ({})); + if (body.error === "auth_required") onAuthRequired(); + } + } + return response; + }; +} + +async function postJson(path, body) { + const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) }); + return { status: response.status, body: await response.json().catch(() => ({})) }; +} + +export function showPinDialog({ t, escapeHtml, onSuccess }) { + if (dialogOpen) return; + dialogOpen = true; + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + const close = () => { overlay.remove(); dialogOpen = false; }; + const error = overlay.querySelector("#authLoginError"); + overlay.querySelector("input").focus(); + overlay.querySelector("#authLoginCancel").addEventListener("click", close); + overlay.querySelector("form").addEventListener("submit", async (event) => { + event.preventDefault(); + const { status } = await postJson("/api/auth/login", { pin: new FormData(event.currentTarget).get("pin") }); + if (status === 200) { + close(); + onSuccess?.(); + return; + } + error.textContent = t(status === 429 ? "auth.too_many" : "auth.wrong_pin"); + error.hidden = false; + }); +} + +export async function mountAuthSettings(container, { t, escapeHtml }) { + if (!container) return; + const status = await fetch("/api/auth/status").then((res) => res.json()).catch(() => null); + if (!status) { + container.innerHTML = `

${escapeHtml(t("auth.status_error"))}

`; + return; + } + const remount = () => mountAuthSettings(container, { t, escapeHtml }); + const stateText = !status.enabled ? t("auth.state_off") : status.authenticated ? t("auth.state_on_logged_in") : t("auth.state_on_logged_out"); + container.innerHTML = ` +

${escapeHtml(stateText)}

+

${escapeHtml(t("auth.hint"))}

+
+ + +
+ + ${status.enabled ? ` + ` : ""} +
+ +
`; + const error = container.querySelector("#authPinError"); + container.querySelector("#authPinForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + if (form.get("newPin") !== form.get("confirmPin")) { + error.textContent = t("auth.pin_mismatch"); + error.hidden = false; + return; + } + const { status: code, body } = await postJson("/api/auth/pin", { newPin: form.get("newPin") }); + if (code !== 200) { + error.textContent = code === 401 ? t("auth.login_required") : body.error || t("auth.status_error"); + error.hidden = false; + return; + } + remount(); + }); + container.querySelector("#authLogout")?.addEventListener("click", async () => { await postJson("/api/auth/logout"); remount(); }); + container.querySelector("#authDisable")?.addEventListener("click", async () => { + if (!window.confirm(t("auth.disable_confirm"))) return; + const { status: code } = await postJson("/api/auth/disable"); + if (code === 401) { + error.textContent = t("auth.login_required"); + error.hidden = false; + return; + } + remount(); + }); +} diff --git a/src/misc_views.js b/src/misc_views.js index efa70e9..b782c97 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -97,7 +97,7 @@ export function renderGuideView({ dom, t }) {
- ${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7", "guide.race_day_8"], "", { t })} + ${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7", "guide.race_day_8", "guide.race_day_9"], "", { t })} ${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6", "guide.manage_steps_7", "guide.manage_steps_8", "guide.manage_steps_9"], "", { t })}
diff --git a/src/settings.js b/src/settings.js index ce508d0..536b557 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,4 +1,5 @@ import { decoderDiagnosticsMarkup, mountDecoderDiagnostics } from "./decoder_diagnostics_view.js"; +import { mountAuthSettings } from "./auth_view.js"; let activeSettingsTab = "connection"; @@ -496,6 +497,11 @@ export function renderSettings(deps) {
+
+

${t("auth.title")}

+
+
+

${t("settings.update_title")}

@@ -634,6 +640,7 @@ Erik Eriksson;654321;4WD;Associated;; button.addEventListener("click", () => applySettingsTab(button.dataset.settingsTab)); }); applySettingsTab(settingsTabButtons.some((button) => button.dataset.settingsTab === activeSettingsTab) ? activeSettingsTab : "connection"); + mountAuthSettings(document.getElementById("authSettings"), { t, escapeHtml }); const applyRaceAudioFormValues = (form) => { const events = {}; diff --git a/src/styles.css b/src/styles.css index 5bb7158..073d93f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4416,3 +4416,15 @@ select:focus { .decoder-diagnostics-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 12px; } .decoder-diagnostics-cards pre, .decoder-diagnostics-log { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; } .decoder-diagnostics-log { max-height: 240px; overflow: auto; } + +.auth-dialog { + max-width: 420px; + padding: 20px 22px; + display: grid; + gap: 12px; +} + +.auth-dialog h3, +.auth-dialog p { + margin: 0; +} diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..271ca9c --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const require = createRequire(import.meta.url); +const { createAuth, parseCookies, parseAllowedRanges, isIpAllowed, TOKEN_MAX_AGE_MS } = require("../server/auth.js"); + +function withAuth(options, run) { + const dataDir = mkdtempSync(join(tmpdir(), "live-rc-auth-")); + try { + return run(createAuth({ dataDir, ...options }), dataDir); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +} + +const request = (method, path, cookie = "", ip = "127.0.0.1") => ({ method, path, ip, headers: { cookie } }); + +test("auth is off until a PIN is set, and then only the right PIN verifies", () => withAuth({}, (auth, dataDir) => { + assert.equal(auth.isEnabled(), false); + assert.throws(() => auth.setPin("12"), /at least 4/); + auth.setPin("4711"); + assert.equal(auth.isEnabled(), true); + assert.equal(auth.verifyPin("4711"), true); + assert.equal(auth.verifyPin("0000"), false); + assert.ok(!readFileSync(join(dataDir, "auth.json"), "utf8").includes("4711"), "the PIN is stored hashed, never in clear text"); + auth.disable(); + assert.equal(auth.isEnabled(), false); + assert.equal(existsSync(join(dataDir, "auth.json")), false); +})); + +test("tokens expire, and changing the PIN invalidates every earlier token", () => { + let clock = 1_000_000; + withAuth({ now: () => clock }, (auth) => { + auth.setPin("4711"); + const token = auth.issueToken(); + assert.equal(auth.verifyToken(token), true); + assert.equal(auth.verifyToken(`${token}0`), false, "a tampered signature is rejected"); + clock += TOKEN_MAX_AGE_MS + 1; + assert.equal(auth.verifyToken(token), false, "an expired token is rejected"); + clock = 1_000_000; + auth.setPin("9999"); + assert.equal(auth.verifyToken(token), false, "a token from before the PIN change is rejected"); + }); +}); + +test("middleware protects writes and the AMMC config read, but leaves other reads open", () => withAuth({}, (auth) => { + auth.setPin("4711"); + const outcome = (req) => { + let status = "next"; + auth.middleware(req, { status: (code) => ({ json: () => { status = code; } }) }, () => {}); + return status; + }; + assert.equal(outcome(request("POST", "/api/state")), 401); + assert.equal(outcome(request("POST", "/api/update/run")), 401); + assert.equal(outcome(request("GET", "/api/ammc/config")), 401); + assert.equal(outcome(request("GET", "/api/state")), "next", "the public overlay still reads state"); + assert.equal(outcome(request("POST", "/api/auth/login")), "next"); + assert.equal(outcome(request("POST", "/public-training/api/token/markers")), "next", "public self-service routes have their own token model"); + assert.equal(outcome(request("POST", "/api/state", `rc_auth=${auth.issueToken()}`)), "next"); +})); + +test("failed logins are throttled per address", () => withAuth({}, (auth) => { + for (let i = 0; i < 9; i += 1) auth.registerLoginFailure("10.0.0.5"); + assert.equal(auth.isLoginBlocked("10.0.0.5"), false); + auth.registerLoginFailure("::ffff:10.0.0.5"); + assert.equal(auth.isLoginBlocked("10.0.0.5"), true, "IPv4-mapped IPv6 counts as the same client"); + assert.equal(auth.isLoginBlocked("10.0.0.6"), false); +})); + +test("IP allowlist matches CIDRs and exact addresses, and an empty list allows everyone", () => { + const ranges = parseAllowedRanges("192.168.1.0/24, 10.0.0.7, ::1"); + assert.equal(isIpAllowed("192.168.1.42", ranges), true); + assert.equal(isIpAllowed("::ffff:192.168.1.42", ranges), true); + assert.equal(isIpAllowed("192.168.2.1", ranges), false); + assert.equal(isIpAllowed("10.0.0.7", ranges), true); + assert.equal(isIpAllowed("::1", ranges), true); + assert.equal(isIpAllowed("8.8.8.8", parseAllowedRanges("")), true); + withAuth({ allowedCidrs: "192.168.1.0/24" }, (auth) => { + let status = "next"; + auth.middleware(request("POST", "/api/state", "", "172.16.0.1"), { status: (code) => ({ json: () => { status = code; } }) }, () => {}); + assert.equal(status, 403, "the allowlist applies even while no PIN is set"); + }); +}); + +test("parseCookies reads a cookie among others", () => { + assert.deepEqual(parseCookies("a=1; rc_auth=abc.def; b=2"), { a: "1", rc_auth: "abc.def", b: "2" }); + assert.deepEqual(parseCookies(""), {}); +}); diff --git a/tests/browser_auth.js b/tests/browser_auth.js new file mode 100644 index 0000000..9f940e2 --- /dev/null +++ b/tests/browser_auth.js @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; + +// End-to-end admin PIN flow through the real UI: enable a PIN in Settings, log out, make a +// normal edit, get prompted by the login dialog the 401 triggers, reject a wrong PIN, accept +// the right one, confirm the pending edit actually reaches the backend, then turn the PIN +// off again so the rest of the shared suite keeps running unauthenticated. +export async function runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor }) { + const statusText = () => evaluate(`document.querySelector("#authSettings strong")?.textContent || ""`); + const openSystemTab = async () => { + await navigate("settings", "settingsForm"); + await evaluate(`document.querySelector('[data-settings-tab="system"]').click()`); + await waitFor(async () => Boolean(await evaluate(`document.getElementById("authPinForm")`)), "admin PIN panel rendered"); + }; + + await openSystemTab(); + assert.match(await statusText(), /^Off/); + await fill("authPinForm", { newPin: "4711", confirmPin: "4711" }); + await evaluate(`document.getElementById("authPinForm").requestSubmit()`); + await waitFor(async () => /logged in in this browser/.test(await statusText()), "PIN enabled and logged in"); + + await evaluate(`document.getElementById("authLogout").click()`); + await waitFor(async () => /not logged in/.test(await statusText()), "logged out"); + console.log("PASS enabling the admin PIN in Settings logs this browser in, and logging out works"); + + try { + await navigate("classes", "classForm"); + await fill("classForm", { name: "Auth Protected Class" }); + await evaluate(`document.getElementById("classForm").requestSubmit()`); + await waitFor(async () => Boolean(await evaluate(`document.getElementById("authLoginForm")`)), "a blocked save opens the PIN dialog"); + assert.ok(!(await savedState()).classes.some((item) => item.name === "Auth Protected Class"), "nothing is saved without the PIN"); + + await evaluate(`(() => { const form = document.getElementById("authLoginForm"); form.elements.namedItem("pin").value = "0000"; form.requestSubmit(); })()`); + await waitFor(async () => (await evaluate(`document.getElementById("authLoginError")?.hidden`)) === false, "wrong PIN shows an error"); + + await evaluate(`(() => { const form = document.getElementById("authLoginForm"); form.elements.namedItem("pin").value = "4711"; form.requestSubmit(); })()`); + await waitFor(async () => !(await evaluate(`Boolean(document.getElementById("authLoginForm"))`)), "dialog closes on the right PIN"); + await waitFor(async () => (await savedState()).classes.some((item) => item.name === "Auth Protected Class"), "the pending edit is saved after logging in"); + console.log("PASS a save while logged out prompts for the PIN, rejects a wrong one, and goes through after the right one"); + } finally { + await evaluate(`fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pin: "4711" }) }).then(() => fetch("/api/auth/disable", { method: "POST" }))`); + } + + await openSystemTab(); + assert.match(await statusText(), /^Off/, "PIN protection is off again for the rest of the suite"); +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 877319e..7d1b76e 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -7,6 +7,7 @@ import { runEventManagerTabsSmoke } from "./browser_event_manager_tabs.js"; import { runEventScheduleSmoke } from "./browser_event_schedule.js"; import { runSettingsTabsSmoke } from "./browser_settings_tabs.js"; import { runGuideTabsSmoke } from "./browser_guide_tabs.js"; +import { runAuthSmoke } from "./browser_auth.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; @@ -144,6 +145,7 @@ try { await runEventScheduleSmoke({ evaluate, navigate, fill, savedState, waitFor, base }); await runSettingsTabsSmoke({ evaluate, navigate }); await runGuideTabsSmoke({ evaluate, navigate }); + await runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor }); await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs }); await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick }); diff --git a/tests/run.js b/tests/run.js index c4c44ad..5d221a0 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,3 +1,4 @@ +import "./auth.test.js"; import "./driver_profile_logic.test.js"; import "./training_logic.test.js"; import "./public_training_profile.test.js"; diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index e73f578..7e6f79c 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -529,3 +529,46 @@ test("PDF export renders Swedish åäö/ÅÄÖ correctly instead of mojibake", { rmSync(dataDir, { recursive: true, force: true }); } }); + +test("an admin PIN blocks unauthenticated writes, and the login cookie lets them through", { timeout: 20000 }, async () => { + const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-auth-")); + const port = await reservePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const child = spawn(process.execPath, [join(projectRoot, "server.js")], { + cwd: projectRoot, + env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + const post = (path, body, cookie = "") => fetch(`${baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, body: JSON.stringify(body) }); + + try { + await waitForServer(child); + assert.equal((await post("/api/state", { classes: [] })).status, 200, "auth is off by default, so writes work as before"); + + const setPin = await post("/api/auth/pin", { newPin: "4711" }); + assert.equal(setPin.status, 200); + const cookie = setPin.headers.get("set-cookie").split(";")[0]; + + assert.equal((await post("/api/state", { classes: [] })).status, 401, "without the cookie a write is rejected"); + assert.equal((await post("/api/update/run", {})).status, 401, "the update/restart trigger is protected too"); + assert.equal((await fetch(`${baseUrl}/api/state`)).status, 200, "reads stay open for the public overlay"); + assert.equal((await post("/api/auth/pin", { newPin: "9999" })).status, 401, "the PIN cannot be changed without logging in"); + assert.equal((await post("/api/state", { classes: [] }, cookie)).status, 200, "the cookie from setting the PIN authorizes writes"); + + assert.equal((await post("/api/auth/login", { pin: "0000" })).status, 401); + const login = await post("/api/auth/login", { pin: "4711" }); + assert.equal(login.status, 200); + assert.ok(login.headers.get("set-cookie").includes("HttpOnly")); + + const status = await (await fetch(`${baseUrl}/api/auth/status`, { headers: { Cookie: cookie } })).json(); + assert.deepEqual(status, { enabled: true, authenticated: true, ipAllowed: true }); + const stateBody = await (await fetch(`${baseUrl}/api/state`)).text(); + assert.ok(!stateBody.includes("pinHash"), "the PIN hash never appears in the state payload"); + + assert.equal((await post("/api/auth/disable", {}, cookie)).status, 200); + assert.equal((await post("/api/state", { classes: [] })).status, 200, "disabling restores open writes"); + } finally { + await stopServer(child); + rmSync(dataDir, { recursive: true, force: true }); + } +});