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>
156 lines
6.4 KiB
JavaScript
156 lines
6.4 KiB
JavaScript
// 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 };
|