NGU-Web/server/src/auth.js

230 lines
7.8 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
AUTH
Everything that decides who someone is. Routes decide what they
may do.
Password storage is scrypt from node:crypto — no native module,
nothing to compile, nothing for pnpm to get wrong. The stored
string carries its own parameters, so raising the cost later
doesn't invalidate existing hashes.
Sessions are opaque random tokens. The browser holds the token
in an HttpOnly cookie; the database holds only its SHA-256. A
leaked backup therefore contains no usable session, and signing
someone out is a DELETE rather than a wait for expiry.
═══════════════════════════════════════════════════════════════ */
import {
createHash,
randomBytes,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
export const COOKIE_NAME = "ngu_session";
const SESSION_DAYS = 30;
// Re-issue the expiry when a session is used with less than this
// left, so an active person is never signed out mid-task.
const REFRESH_WITHIN_DAYS = 7;
// Off only for plain-http local work. Production is behind TLS.
const SECURE_COOKIE = (process.env.COOKIE_SECURE ?? "true") !== "false";
const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
/* ── Passwords ─────────────────────────────────────────────────
Format: scrypt$N$r$p$saltHex$keyHex
N=16384 r=8 p=1 is the standard interactive cost, roughly
100ms per hash on this class of hardware.
───────────────────────────────────────────────────────────── */
const SCRYPT = { N: 16384, r: 8, p: 1, keyLen: 64 };
export function hashPassword(password) {
const salt = randomBytes(16);
const key = scryptSync(password.normalize("NFKC"), salt, SCRYPT.keyLen, {
N: SCRYPT.N,
r: SCRYPT.r,
p: SCRYPT.p,
// scrypt's default memory cap is below what N=16384 needs.
maxmem: 256 * 1024 * 1024,
});
return [
"scrypt",
SCRYPT.N,
SCRYPT.r,
SCRYPT.p,
salt.toString("hex"),
key.toString("hex"),
].join("$");
}
export function verifyPassword(password, stored) {
if (typeof stored !== "string") return false;
const [scheme, N, r, p, saltHex, keyHex] = stored.split("$");
if (scheme !== "scrypt") return false;
let candidate;
try {
candidate = scryptSync(
password.normalize("NFKC"),
Buffer.from(saltHex, "hex"),
Buffer.from(keyHex, "hex").length,
{ N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024 },
);
} catch {
return false;
}
const expected = Buffer.from(keyHex, "hex");
if (candidate.length !== expected.length) return false;
return timingSafeEqual(candidate, expected);
}
/* ── Hashing helpers ─────────────────────────────────────────── */
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
export function hashIp(ip) {
if (!ip) return null;
return sha256(`${IP_SALT}:${ip}`).slice(0, 32);
}
export function clientIp(c) {
// Trustworthy only because this service binds to 127.0.0.1 and
// nginx is the only thing that can reach it.
return c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? null;
}
/* ── Sessions ────────────────────────────────────────────────── */
export function createSession(db, c, userId) {
const token = randomBytes(32).toString("base64url");
db.prepare(
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, ip_hash)
VALUES (?, ?, datetime('now', ?), ?, ?)`,
).run(
sha256(token),
userId,
`+${SESSION_DAYS} days`,
c.req.header("user-agent")?.slice(0, 500) ?? null,
hashIp(clientIp(c)),
);
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: SECURE_COOKIE,
// Lax, not Strict: Strict would drop the cookie when someone
// follows a link into /admin from elsewhere, which reads as
// being randomly signed out. Lax still blocks the cross-site
// POST that CSRF depends on.
sameSite: "Lax",
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
return token;
}
export function destroySession(db, c) {
const token = getCookie(c, COOKIE_NAME);
if (token) {
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token));
}
deleteCookie(c, COOKIE_NAME, { path: "/", secure: SECURE_COOKIE });
}
export function destroyAllSessionsFor(db, userId) {
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
}
/* Returns the signed-in user, or null. Also slides the expiry
forward when the session is getting old. */
export function currentUser(db, c) {
const token = getCookie(c, COOKIE_NAME);
if (!token) return null;
const tokenHash = sha256(token);
const row = db
.prepare(
`SELECT s.id AS session_id,
s.expires_at AS expires_at,
u.id, u.email, u.name, u.role
FROM sessions s
JOIN admin_users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.expires_at > datetime('now')
AND u.is_active = 1`,
)
.get(tokenHash);
if (!row) return null;
const refreshDue = db
.prepare("SELECT ? < datetime('now', ?) AS due")
.get(row.expires_at, `+${REFRESH_WITHIN_DAYS} days`);
if (refreshDue?.due) {
db.prepare(
`UPDATE sessions
SET expires_at = datetime('now', ?), last_seen_at = datetime('now')
WHERE id = ?`,
).run(`+${SESSION_DAYS} days`, row.session_id);
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: SECURE_COOKIE,
sameSite: "Lax",
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
}
return { id: row.id, email: row.email, name: row.name, role: row.role };
}
/* ── Middleware ──────────────────────────────────────────────── */
export async function requireAuth(c, next) {
const user = currentUser(c.get("db"), c);
if (!user) return c.json({ error: "Not signed in." }, 401);
c.set("user", user);
await next();
}
export const ROLES = ["viewer", "editor", "admin", "superadmin"];
const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 };
export function requireRole(...roles) {
const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity));
return async (c, next) => {
const user = c.get("user");
if (!user || (RANK[user.role] ?? 0) < need) {
return c.json({ error: "Not allowed." }, 403);
}
await next();
};
}
/* ── Housekeeping ──────────────────────────────────────────────
Expired rows are already ignored by every query; this just
stops the table growing without bound.
───────────────────────────────────────────────────────────── */
export function startSessionSweeper(db, everyMs = 6 * 60 * 60 * 1000) {
const sweep = () => {
try {
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
} catch (err) {
console.error("session sweep failed", err);
}
};
sweep();
setInterval(sweep, everyMs).unref();
}