212 lines
8.0 KiB
TypeScript
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) };
|
|
});
|
|
}
|