Client: - Commit: batch `p4 reopen` so huge changelists (63k+) no longer blow the Windows command-line limit; live "N / total" progress + spinner during commit. - Submit dialog: white arrow, "N of total" + % bar, running file log, Cancel button (aborts pre-commit; changelist stays pending). - Disk scan: streaming reconcile with a live "found N files" counter + Cancel. - Thumbnails: only decode browser-renderable image formats; glyph fallback (+ onError) instead of a broken-image icon for bmp/tga/dds/etc. Landing + waitlist: - waitlist-server: Express + SQLite email collector (dedup) with a secure admin panel (scrypt auth, rate-limited login, signed cookies) and CSV export. - landing form now POSTs signups to the waitlist API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
4.5 KiB
JavaScript
130 lines
4.5 KiB
JavaScript
// Authentication for the admin panel — no external crypto deps, all from node:crypto.
|
|
//
|
|
// • Passwords are stored as scrypt hashes ("scrypt$salt$hash", both base64url) in
|
|
// the ADMIN_PASS_HASH env var. Never a plaintext password anywhere.
|
|
// • Sessions are stateless, signed cookies: base64url(payload).base64url(hmac).
|
|
// Tamper-proof (HMAC-SHA256 with SESSION_SECRET) and self-expiring. No server
|
|
// session store to leak or lose on restart.
|
|
// • Every compare is constant-time (timingSafeEqual) to avoid timing oracles.
|
|
|
|
import crypto from "node:crypto";
|
|
|
|
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
|
|
|
|
/** Hash a plaintext password → "scrypt$<salt>$<hash>" (for storing in .env). */
|
|
export function hashPassword(password) {
|
|
const salt = crypto.randomBytes(16);
|
|
const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
|
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
|
|
}
|
|
|
|
/** Verify a plaintext password against a stored "scrypt$salt$hash" string. */
|
|
export function verifyPassword(password, stored) {
|
|
if (typeof stored !== "string") return false;
|
|
const parts = stored.split("$");
|
|
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
|
let salt, expected;
|
|
try {
|
|
salt = Buffer.from(parts[1], "base64url");
|
|
expected = Buffer.from(parts[2], "base64url");
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (expected.length !== SCRYPT.keylen) return false;
|
|
const got = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
|
return crypto.timingSafeEqual(got, expected);
|
|
}
|
|
|
|
/** Constant-time string compare (for the username). */
|
|
export function safeEqual(a, b) {
|
|
const ba = Buffer.from(String(a));
|
|
const bb = Buffer.from(String(b));
|
|
if (ba.length !== bb.length) return false;
|
|
return crypto.timingSafeEqual(ba, bb);
|
|
}
|
|
|
|
// ---- signed session cookies -------------------------------------------------
|
|
|
|
const COOKIE_NAME = "exd_admin";
|
|
|
|
function sign(data, secret) {
|
|
return crypto.createHmac("sha256", secret).update(data).digest("base64url");
|
|
}
|
|
|
|
/** Build a signed session token that expires after `ttlMs`. */
|
|
export function issueToken(username, secret, ttlMs) {
|
|
const payload = JSON.stringify({ u: username, exp: Date.now() + ttlMs });
|
|
const body = Buffer.from(payload).toString("base64url");
|
|
return `${body}.${sign(body, secret)}`;
|
|
}
|
|
|
|
/** Verify a session token → { u } if valid & unexpired, else null. */
|
|
export function verifyToken(token, secret) {
|
|
if (typeof token !== "string" || !token.includes(".")) return null;
|
|
const [body, mac] = token.split(".");
|
|
if (!body || !mac) return null;
|
|
const expected = sign(body, secret);
|
|
const a = Buffer.from(mac);
|
|
const b = Buffer.from(expected);
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!payload || typeof payload.exp !== "number" || Date.now() > payload.exp) return null;
|
|
return { u: payload.u };
|
|
}
|
|
|
|
/** Parse the admin session cookie out of a raw Cookie header. */
|
|
export function readSessionCookie(req) {
|
|
const raw = req.headers.cookie || "";
|
|
for (const part of raw.split(";")) {
|
|
const i = part.indexOf("=");
|
|
if (i === -1) continue;
|
|
if (part.slice(0, i).trim() === COOKIE_NAME) {
|
|
return decodeURIComponent(part.slice(i + 1).trim());
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function setSessionCookie(res, token, { secure, ttlMs }) {
|
|
const attrs = [
|
|
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
|
"HttpOnly",
|
|
"SameSite=Strict",
|
|
"Path=/",
|
|
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
|
];
|
|
if (secure) attrs.push("Secure");
|
|
res.append("Set-Cookie", attrs.join("; "));
|
|
}
|
|
|
|
export function clearSessionCookie(res, { secure }) {
|
|
const attrs = [`${COOKIE_NAME}=`, "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=0"];
|
|
if (secure) attrs.push("Secure");
|
|
res.append("Set-Cookie", attrs.join("; "));
|
|
}
|
|
|
|
/** Express middleware: allow through only requests with a valid session. */
|
|
export function requireAuth(secret) {
|
|
return (req, res, next) => {
|
|
const tok = readSessionCookie(req);
|
|
const sess = tok && verifyToken(tok, secret);
|
|
if (!sess) {
|
|
// req.path is stripped of the mount prefix, so match on originalUrl
|
|
const url = req.originalUrl || req.url || "";
|
|
if (url.startsWith("/admin/api") || url.endsWith(".csv")) {
|
|
return res.status(401).json({ error: "unauthorized" });
|
|
}
|
|
return res.redirect("/admin/login");
|
|
}
|
|
req.admin = sess;
|
|
next();
|
|
};
|
|
}
|
|
|
|
export { COOKIE_NAME };
|