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:
155
server/auth.js
Normal file
155
server/auth.js
Normal 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 };
|
||||
Reference in New Issue
Block a user