230 lines
7.8 KiB
JavaScript
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();
|
|
}
|