Files
exbyte-depot-viewer-perforce/waitlist-server/lib/auth.js
Bonchellon 3954082e8d v0.3.2 — commit/submit progress, cancel, thumbnails, waitlist landing+admin
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>
2026-07-08 20:40:53 +03:00

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