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

155
server/auth.js Normal file
View File

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