feat(security): require first-install Admin setup and authenticated login with Argon2id + HttpOnly SHA-256 sessions

This commit is contained in:
Schwitzz
2026-10-30 16:05:44 -05:00
parent 65d06e1681
commit 09e9280da6
8 changed files with 1092 additions and 170 deletions
+215 -42
View File
@@ -10,11 +10,30 @@ 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);
// ---- password hashing (OWASP Argon2id parameters) ----
const ARGON2_OPTS: argon2.Options = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3,
parallelism: 4,
};
// ---- session tokens (store only the SHA-256 hash) ----
export const hashPassword = (pw: string): Promise<string> =>
argon2.hash(pw, ARGON2_OPTS);
export const verifyPassword = (hash: string, pw: string): Promise<boolean> =>
argon2.verify(hash, pw);
// Precomputed dummy hash to prevent timing-based user enumeration on login
let dummyHashPromise: Promise<string> | null = null;
function getDummyHash(): Promise<string> {
if (!dummyHashPromise) {
dummyHashPromise = hashPassword("pubscript-dummy-anti-enumeration-secret");
}
return dummyHashPromise;
}
// ---- session tokens (256-bit CSPRNG; store only the SHA-256 hash in SQLite) ----
export function newToken(): string {
return crypto.randomBytes(32).toString("hex");
}
@@ -25,11 +44,16 @@ function tokenHash(token: string): string {
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(
const nowIso = new Date(now).toISOString();
// Prune expired sessions opportunistically
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(nowIso);
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(),
nowIso,
);
return token;
}
@@ -51,7 +75,7 @@ export function userFromToken(db: Db, token: string | null | undefined): UserRow
return row ?? null;
}
// ---- tiny in-memory fixed-window rate limiter ----
// ---- 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 => {
@@ -74,19 +98,48 @@ export function bearerToken(req: FastifyRequest): string | null {
}
export function registerAuthHook(app: FastifyInstance): void {
// Purge legacy auto-bootstrapped placeholder user if it is the only user in DB
try {
const users = app.db
.prepare("SELECT id, email FROM users")
.all() as Array<{ id: string; email: string }>;
if (users.length === 1 && users[0]?.email === "admin@pubscript.local" && !process.env.VITEST) {
const legacyId = users[0].id;
app.db.prepare("DELETE FROM sessions WHERE user_id = ?").run(legacyId);
app.db.prepare("DELETE FROM users WHERE id = ?").run(legacyId);
}
} catch {
/* ignore */
}
app.addHook("onRequest", async (req) => {
const db = app.db;
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
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 {
function isSecureRequest(req: FastifyRequest): boolean {
if (req.protocol === "https") return true;
const proto = req.headers["x-forwarded-proto"];
if (typeof proto === "string" && proto.split(",")[0]?.trim().toLowerCase() === "https") {
return true;
}
return false;
}
export function setSessionCookie(
reply: FastifyReply,
token: string,
req?: FastifyRequest,
): void {
reply.setCookie(SESSION_COOKIE, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: req ? isSecureRequest(req) : false,
maxAge: SESSION_TTL_MS / 1000,
});
}
@@ -100,9 +153,16 @@ export function requireAuth(req: FastifyRequest): void {
}
export function requireAdmin(req: FastifyRequest): void {
requireAuth(req);
if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" });
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 {
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;
@@ -116,24 +176,85 @@ export function canAccessProject(db: Db, user: User, projectId: string, needWrit
}
// ---- auth routes ----
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(1).max(80),
const setupSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(8).max(256),
displayName: z.string().trim().min(1).max(80),
});
const loginSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(1).max(256),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(256),
});
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);
// Check whether the instance has been initialized with an admin user and whether the caller is authenticated
app.get("/api/auth/status", async (req) => {
const count = (
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
).n;
return {
initialized: count > 0,
authenticated: Boolean(req.user),
user: req.user ?? null,
};
});
// First-install administrator setup (only permitted when 0 users exist in DB)
app.post("/api/auth/setup", async (req, reply) => {
if (!authLimiter(req.ip)) {
throw app.httpErrors.create(429, { error: "rate_limited" });
}
const db = app.db;
const userCount = (
db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
).n;
if (userCount > 0) {
throw app.httpErrors.create(403, {
error: "already_initialized",
message: "Administrator account has already been created. Please sign in.",
});
}
const body = setupSchema.parse(req.body);
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 (?, ?, ?, ?, 'admin', 0, ?)",
).run(id, body.email, passHash, body.displayName, now);
db.prepare(
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.setup', ?)",
).run(ulid(), now, id, JSON.stringify({ email: body.email, ip: req.ip }));
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
const token = createSession(db, id);
setSessionCookie(reply, token, req);
return { token, user: toUser(row) };
});
// Register: creates first admin if DB has 0 users; otherwise requires an authenticated Admin
app.post("/api/auth/register", async (req, reply) => {
if (!authLimiter(req.ip)) {
throw app.httpErrors.create(429, { error: "rate_limited" });
}
const db = app.db;
const isFirst =
(db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
if (!isFirst) {
requireAdmin(req);
}
const body = setupSchema.parse(req.body);
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();
@@ -141,49 +262,107 @@ export function registerAuthRoutes(app: FastifyInstance): void {
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 }),
);
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);
setSessionCookie(reply, token, req);
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 rateKey = `${req.ip}:${body.email}`;
if (!authLimiter(rateKey)) {
throw app.httpErrors.create(429, { error: "rate_limited" });
}
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))) {
const row = db
.prepare("SELECT * FROM users WHERE LOWER(email) = ?")
.get(body.email) as UserRow | undefined;
if (!row) {
await verifyPassword(await getDummyHash(), body.password);
throw app.httpErrors.create(401, { error: "bad_credentials" });
}
const valid = await verifyPassword(row.pass_hash, body.password);
if (!valid || row.disabled) {
db.prepare(
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login_failed', ?)",
).run(
ulid(),
new Date().toISOString(),
row.id,
JSON.stringify({ email: body.email, ip: req.ip }),
);
throw app.httpErrors.create(401, { error: "bad_credentials" });
}
db.prepare(
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login', ?)",
).run(
ulid(),
new Date().toISOString(),
row.id,
JSON.stringify({ email: body.email, ip: req.ip }),
);
const token = createSession(db, row.id);
setSessionCookie(reply, token);
setSessionCookie(reply, token, req);
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;
const token =
bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
if (token) destroySession(app.db, token);
clearSessionCookie(reply);
return { ok: true };
});
app.post("/api/auth/change-password", async (req, reply) => {
requireAuth(req);
const body = changePasswordSchema.parse(req.body);
const db = app.db;
const row = db
.prepare("SELECT * FROM users WHERE id = ?")
.get(req.user!.id) as UserRow | undefined;
if (!row || !(await verifyPassword(row.pass_hash, body.currentPassword))) {
throw app.httpErrors.create(401, { error: "bad_credentials" });
}
const nextHash = await hashPassword(body.newPassword);
db.prepare("UPDATE users SET pass_hash = ? WHERE id = ?").run(nextHash, row.id);
// Revoke all existing sessions for this user and issue a fresh one
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(row.id);
const token = createSession(db, row.id);
setSessionCookie(reply, token, req);
db.prepare(
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.password_change', ?)",
).run(ulid(), new Date().toISOString(), row.id, JSON.stringify({ ip: req.ip }));
return { ok: true, token };
});
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) => {
// Test-only bootstrap helper (disabled in production once initialized)
app.post("/api/auth/bootstrap-admin", async (req, reply) => {
if (!process.env.VITEST && process.env.NODE_ENV !== "test") {
throw app.httpErrors.create(403, {
error: "bootstrap_disabled",
message: "Automatic bootstrap is disabled. Use /api/auth/setup or /api/auth/login.",
});
}
const db = app.db;
let adminRow = db
.prepare("SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1")
.prepare(
"SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1",
)
.get() as UserRow | undefined;
if (!adminRow) {
@@ -195,17 +374,11 @@ export function registerAuthRoutes(app: FastifyInstance): void {
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);
setSessionCookie(reply, token, req);
return { token, user: toUser(adminRow) };
});
}
+6 -1
View File
@@ -33,7 +33,12 @@ export interface ServerConfig {
}
export const config: ServerConfig = {
dbPath: env("PUBSCRIPT_DB", "./data/pubscript.sqlite"),
dbPath: env(
"PUBSCRIPT_DB",
process.env.VITEST || process.env.NODE_ENV === "test"
? ":memory:"
: "./data/pubscript.sqlite",
),
port: envInt("PORT", 8080),
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
+17 -2
View File
@@ -20,7 +20,13 @@ import websocket from "@fastify/websocket";
import { ZodError } from "zod";
import { config } from "./config.js";
import { openDb, ensureAdmin } from "./db.js";
import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js";
import {
hashPassword,
registerAuthHook,
registerAuthRoutes,
requireAdmin,
requireAuth,
} from "./auth.js";
import { registerRoutes } from "./routes.js";
import { registerWs } from "./ws.js";
import { registerAdminRoutes } from "./admin.js";
@@ -201,6 +207,11 @@ export async function buildApp() {
}
app.addHook("onRequest", async (req, reply) => {
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "SAMEORIGIN");
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
const cfg = loadPersistedServerSysConfig();
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
@@ -322,7 +333,8 @@ export async function buildApp() {
}
}
app.get("/api/system/server-config", async () => {
app.get("/api/system/server-config", async (req) => {
requireAuth(req);
const persisted = loadPersistedServerSysConfig();
return {
ok: true,
@@ -342,6 +354,7 @@ export async function buildApp() {
});
app.post("/api/system/server-config", async (req, reply) => {
requireAdmin(req);
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
const current = loadPersistedServerSysConfig();
const nextPort =
@@ -397,6 +410,7 @@ export async function buildApp() {
});
app.get("/api/system/list-dirs", async (req) => {
requireAuth(req);
const query = (req.query ?? {}) as { path?: string };
const rawTarget = query.path ? expandTilde(query.path) : homedir();
const target = existsSync(rawTarget) ? rawTarget : homedir();
@@ -429,6 +443,7 @@ export async function buildApp() {
});
app.post("/api/system/save-export", async (req, reply) => {
requireAuth(req);
const body = (req.body ?? {}) as {
directory?: string;
fileName?: string;
+76
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach } from "vitest";
process.env.PUBSCRIPT_DB = ":memory:";
import { buildApp } from "./index.js";
afterEach(async () => {
@@ -96,4 +97,79 @@ describe("admin endpoints", () => {
await app.close();
}
});
it("enforces first-install setup lock, login authentication, and protected system routes", async () => {
const app = await buildApp();
try {
// Unauthenticated system config request must be rejected with 401
const unauthSys = await app.inject({ method: "GET", url: "/api/system/server-config" });
expect(unauthSys.statusCode).toBe(401);
// Clear any users so we test fresh first-install state
app.db.prepare("DELETE FROM sessions").run();
app.db.prepare("DELETE FROM project_shares").run();
app.db.prepare("DELETE FROM projects").run();
app.db.prepare("DELETE FROM users").run();
const statusBefore = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(statusBefore.statusCode).toBe(200);
expect(statusBefore.json()).toEqual({
initialized: false,
authenticated: false,
user: null,
});
const setupRes = await app.inject({
method: "POST",
url: "/api/auth/setup",
payload: {
email: "owner@example.com",
displayName: "Studio Owner",
password: "StrongPassword!2026",
},
});
expect(setupRes.statusCode).toBe(200);
const { token, user } = setupRes.json() as { token: string; user: { role: string } };
expect(token).toBeTruthy();
expect(user.role).toBe("admin");
// Second setup attempt must be forbidden once initialized
const secondSetup = await app.inject({
method: "POST",
url: "/api/auth/setup",
payload: {
email: "attacker@example.com",
displayName: "Attacker",
password: "StrongPassword!2026",
},
});
expect(secondSetup.statusCode).toBe(403);
// Login with wrong password must fail with 401
const badLogin = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { email: "owner@example.com", password: "wrongpassword" },
});
expect(badLogin.statusCode).toBe(401);
// Login with valid password succeeds
const goodLogin = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { email: "owner@example.com", password: "StrongPassword!2026" },
});
expect(goodLogin.statusCode).toBe(200);
// System config works with valid auth token
const authSys = await app.inject({
method: "GET",
url: "/api/system/server-config",
headers: { authorization: `Bearer ${token}` },
});
expect(authSys.statusCode).toBe(200);
} finally {
await app.close();
}
});
});