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 <noreply@anthropic.com>
This commit is contained in:
92
tests/auth.test.js
Normal file
92
tests/auth.test.js
Normal file
@@ -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(""), {});
|
||||
});
|
||||
45
tests/browser_auth.js
Normal file
45
tests/browser_auth.js
Normal file
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user