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:
larssand
2026-09-24 22:14:58 +02:00
parent bcd6e12a6c
commit 9016ce02e4
20 changed files with 627 additions and 8 deletions

View File

@@ -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 });
}
});