128 lines
4.4 KiB
JavaScript
128 lines
4.4 KiB
JavaScript
// Admin dashboard auth. Mirrors Recap's server/admin-auth.js shape so
|
|
// the patterns are familiar. Single-user auth (operator only) — verify
|
|
// scrypt(password, salt) against the stored hash, mint a signed
|
|
// session cookie on success. Cookie is HMAC-signed with a per-install
|
|
// session secret so attackers can't forge sessions even with read
|
|
// access to the relay's /admin endpoints.
|
|
|
|
import { scryptSync, timingSafeEqual, createHmac } from "crypto";
|
|
import { getConfigSnapshot } from "./config.js";
|
|
|
|
const SCRYPT_KEYLEN = 64;
|
|
const SESSION_COOKIE = "recap-relay-admin";
|
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
|
|
// Path prefix that requires admin auth. Public /relay/* paths are
|
|
// authenticated per-call by the route handlers, not the cookie.
|
|
const ADMIN_PREFIX = "/admin";
|
|
|
|
function constantTimeEqual(a, b) {
|
|
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
if (a.length !== b.length) return false;
|
|
try {
|
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function signSessionToken(payload, secret) {
|
|
const body = JSON.stringify(payload);
|
|
const sig = createHmac("sha256", secret).update(body).digest("hex");
|
|
return `${Buffer.from(body).toString("base64url")}.${sig}`;
|
|
}
|
|
|
|
function verifySessionToken(token, secret) {
|
|
if (!token) return null;
|
|
const parts = token.split(".");
|
|
if (parts.length !== 2) return null;
|
|
const [b64, sig] = parts;
|
|
let body;
|
|
try {
|
|
body = Buffer.from(b64, "base64url").toString("utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const expectedSig = createHmac("sha256", secret).update(body).digest("hex");
|
|
if (!constantTimeEqual(sig, expectedSig)) return null;
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(body);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof payload?.exp !== "number" || Date.now() > payload.exp) return null;
|
|
return payload;
|
|
}
|
|
|
|
export function setupAdminAuthMiddleware(app) {
|
|
app.use(async (req, res, next) => {
|
|
if (!req.path.startsWith(ADMIN_PREFIX)) return next();
|
|
// /admin/login is reachable without auth.
|
|
if (req.path === "/admin/login" || req.path === "/admin/status") return next();
|
|
const cfg = await getConfigSnapshot();
|
|
if (!cfg.relay_admin_password_hash) {
|
|
// No password set — admin endpoints are disabled entirely.
|
|
return res.status(401).json({ error: "admin_disabled" });
|
|
}
|
|
const token = req.cookies?.[SESSION_COOKIE];
|
|
const payload = verifySessionToken(token, cfg.relay_admin_session_secret);
|
|
if (!payload) return res.status(401).json({ error: "unauthorized" });
|
|
req.adminUser = payload.user;
|
|
next();
|
|
});
|
|
}
|
|
|
|
export function setupAdminAuthRoutes(app) {
|
|
app.get("/admin/status", async (_req, res) => {
|
|
const cfg = await getConfigSnapshot();
|
|
res.json({
|
|
enabled: !!cfg.relay_admin_password_hash,
|
|
username: cfg.relay_admin_username || "admin",
|
|
});
|
|
});
|
|
|
|
app.post("/admin/login", async (req, res) => {
|
|
const cfg = await getConfigSnapshot();
|
|
if (!cfg.relay_admin_password_hash) {
|
|
return res.status(400).json({ error: "admin_disabled" });
|
|
}
|
|
const { username, password } = req.body || {};
|
|
if (
|
|
!username ||
|
|
!password ||
|
|
typeof username !== "string" ||
|
|
typeof password !== "string"
|
|
) {
|
|
return res.status(400).json({ error: "missing_credentials" });
|
|
}
|
|
if (username.trim() !== (cfg.relay_admin_username || "admin")) {
|
|
return res.status(401).json({ error: "invalid_credentials" });
|
|
}
|
|
const hash = scryptSync(password, cfg.relay_admin_password_salt, SCRYPT_KEYLEN).toString("hex");
|
|
if (!constantTimeEqual(hash, cfg.relay_admin_password_hash)) {
|
|
return res.status(401).json({ error: "invalid_credentials" });
|
|
}
|
|
const token = signSessionToken(
|
|
{ user: username, exp: Date.now() + SESSION_TTL_MS },
|
|
cfg.relay_admin_session_secret
|
|
);
|
|
res.cookie(SESSION_COOKIE, token, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
// secure: true would be ideal but the relay runs behind
|
|
// StartTunnel which terminates TLS — the cookie travels over
|
|
// plain HTTP inside the tunnel. Leave secure false so the
|
|
// cookie sticks; the tunnel itself provides the encryption.
|
|
secure: false,
|
|
maxAge: SESSION_TTL_MS,
|
|
});
|
|
res.json({ ok: true, username });
|
|
});
|
|
|
|
app.post("/admin/logout", (_req, res) => {
|
|
res.clearCookie(SESSION_COOKIE);
|
|
res.json({ ok: true });
|
|
});
|
|
}
|