v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
226
server/src/auth.js
Normal file
226
server/src/auth.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
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 function requireRole(...roles) {
|
||||
return async (c, next) => {
|
||||
const user = c.get("user");
|
||||
if (!user || !roles.includes(user.role)) {
|
||||
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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue