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>
93 lines
4.3 KiB
JavaScript
93 lines
4.3 KiB
JavaScript
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(""), {});
|
|
});
|