Files
PubScript/server/src/auth.ts
T

212 lines
8.0 KiB
TypeScript

import crypto from "node:crypto";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import argon2 from "argon2";
import { ulid } from "ulid";
import { z } from "zod";
import type { User } from "@pubscript/core";
import type { Db } from "./db.js";
import { toUser, type UserRow } from "./mapping.js";
const SESSION_COOKIE = "ps_session";
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
// ---- password hashing ----
export const hashPassword = (pw: string): Promise<string> => argon2.hash(pw, { type: argon2.argon2id });
export const verifyPassword = (hash: string, pw: string): Promise<boolean> => argon2.verify(hash, pw);
// ---- session tokens (store only the SHA-256 hash) ----
export function newToken(): string {
return crypto.randomBytes(32).toString("hex");
}
function tokenHash(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function createSession(db: Db, userId: string): string {
const token = newToken();
const now = Date.now();
db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
tokenHash(token),
userId,
new Date(now + SESSION_TTL_MS).toISOString(),
new Date(now).toISOString(),
);
return token;
}
export function destroySession(db: Db, token: string): void {
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token));
}
/** Resolve a presented token to a live, non-disabled user (or null). */
export function userFromToken(db: Db, token: string | null | undefined): UserRow | null {
if (!token) return null;
const row = db
.prepare(
`SELECT u.id, u.email, u.pass_hash, u.display_name, u.role, u.disabled, u.created_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`,
)
.get(tokenHash(token), new Date().toISOString()) as UserRow | undefined;
return row ?? null;
}
// ---- tiny in-memory fixed-window rate limiter ----
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
const hits = new Map<string, { count: number; resetAt: number }>();
return (key: string): boolean => {
const now = Date.now();
const e = hits.get(key);
if (!e || now > e.resetAt) {
hits.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
e.count += 1;
return e.count <= max;
};
}
// ---- request hook: populate req.user from bearer header or cookie ----
export function bearerToken(req: FastifyRequest): string | null {
const h = req.headers.authorization;
if (h?.startsWith("Bearer ")) return h.slice(7);
return null;
}
export function registerAuthHook(app: FastifyInstance): void {
app.addHook("onRequest", async (req) => {
const db = app.db;
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
const row = userFromToken(db, token);
req.user = row ? toUser(row) : null;
});
}
export function setSessionCookie(reply: FastifyReply, token: string): void {
reply.setCookie(SESSION_COOKIE, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: SESSION_TTL_MS / 1000,
});
}
export function clearSessionCookie(reply: FastifyReply): void {
reply.clearCookie(SESSION_COOKIE, { path: "/" });
}
// ---- guards ----
export function requireAuth(req: FastifyRequest): void {
if (!req.user) throw req.server.httpErrors.create(401, { error: "unauthorized" });
}
export function requireAdmin(req: FastifyRequest): void {
requireAuth(req);
if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" });
}
export function canAccessProject(db: Db, user: User, projectId: string, needWrite = false): boolean {
const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
| { owner_id: string }
| undefined;
if (!proj) return false;
if (proj.owner_id === user.id) return true;
const share = db
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
if (!share) return false;
return needWrite ? share.perm === "edit" : true;
}
// ---- auth routes ----
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(1).max(80),
});
const loginSchema = z.object({ email: z.string().email(), password: z.string() });
export function registerAuthRoutes(app: FastifyInstance): void {
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
app.post("/api/auth/register", async (req, reply) => {
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
const body = registerSchema.parse(req.body);
const db = app.db;
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
if (existing) throw app.httpErrors.create(409, { error: "email_taken" });
const isFirst = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
const role = isFirst ? "admin" : "author";
const id = ulid();
const now = new Date().toISOString();
const passHash = await hashPassword(body.password);
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
).run(id, body.email, passHash, body.displayName, role, now);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run(
ulid(),
now,
id,
JSON.stringify({ email: body.email, first: isFirst }),
);
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
const token = createSession(db, id);
setSessionCookie(reply, token);
return { token, user: toUser(row) };
});
app.post("/api/auth/login", async (req, reply) => {
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
const body = loginSchema.parse(req.body);
const db = app.db;
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined;
if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) {
throw app.httpErrors.create(401, { error: "bad_credentials" });
}
const token = createSession(db, row.id);
setSessionCookie(reply, token);
return { token, user: toUser(row) };
});
app.post("/api/auth/logout", async (req, reply) => {
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
if (token) destroySession(app.db, token);
clearSessionCookie(reply);
return { ok: true };
});
app.get("/api/auth/me", async (req) => {
requireAuth(req);
return { user: req.user };
});
// Bootstrap or sign into the local admin account when no admin session is active yet.
app.post("/api/auth/bootstrap-admin", async (_req, reply) => {
const db = app.db;
let adminRow = db
.prepare("SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1")
.get() as UserRow | undefined;
if (!adminRow) {
const id = ulid();
const now = new Date().toISOString();
const email = process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local";
const password = process.env.PUBSCRIPT_ADMIN_PASSWORD ?? "admin12345";
const passHash = await hashPassword(password);
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
).run(id, email, passHash, "Local Admin", now);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
ulid(),
now,
id,
JSON.stringify({ email }),
);
adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
}
const token = createSession(db, adminRow.id);
setSessionCookie(reply, token);
return { token, user: toUser(adminRow) };
});
}