From 09e9280da6a14edb89f2c0b996644e1016369ec6 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Fri, 30 Oct 2026 16:05:44 -0500 Subject: [PATCH] feat(security): require first-install Admin setup and authenticated login with Argon2id + HttpOnly SHA-256 sessions --- apps/web/src/App.tsx | 53 ++-- apps/web/src/api.ts | 427 ++++++++++++++++++++------- apps/web/src/components/AuthGate.tsx | 404 +++++++++++++++++++++++++ apps/web/src/components/Topbar.tsx | 19 ++ server/src/auth.ts | 257 +++++++++++++--- server/src/config.ts | 7 +- server/src/index.ts | 19 +- server/src/server.test.ts | 76 +++++ 8 files changed, 1092 insertions(+), 170 deletions(-) create mode 100644 apps/web/src/components/AuthGate.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 08f7688..1da354f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { Routes, Route } from "react-router-dom"; import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js"; +import { AuthGate } from "./components/AuthGate.js"; import { ExportDialog } from "./features/export/ExportDialog.js"; import { AISidebar } from "./features/ai/AISidebar.js"; import { ToastViewport } from "@pubscript/ui"; @@ -45,31 +46,33 @@ export default function App() { }, []); return ( -
- - - setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
- {aiSidebarOpen && } -
+ +
+ + + setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ {aiSidebarOpen && } +
- setPaletteOpen(false)} nav={NAV} /> - setExportOpen(false)} /> - -
+ setPaletteOpen(false)} nav={NAV} /> + setExportOpen(false)} /> + +
+ ); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8c8b694..1e5d052 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -2,8 +2,10 @@ import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript import { useSettings } from "./store/settings.js"; const BASE = "/api"; -const TOKEN_KEY = "pubscript.admin.token"; -const LOCAL_USERS_KEY = "pubscript.local.admin.users.v1"; +const TOKEN_KEY = "pubscript.auth.token.v1"; +const LOCAL_AUTH_KEY = "pubscript.local.auth.v2"; +const LOCAL_SESSION_KEY = "pubscript.local.session.v2"; +const LOCAL_USERS_KEY = "pubscript.local.admin.users.v2"; const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1"; export interface AdminStats { @@ -15,7 +17,19 @@ export interface AdminStats { disabledUsers: number; } -function getStoredToken(): string | null { +export interface AuthStatusResponse { + initialized: boolean; + authenticated: boolean; + user: User | null; +} + +interface LocalAuthRecord { + user: User; + saltHex: string; + hashHex: string; +} + +export function getStoredToken(): string | null { try { return localStorage.getItem(TOKEN_KEY); } catch { @@ -23,7 +37,7 @@ function getStoredToken(): string | null { } } -function setStoredToken(token: string | null): void { +export function setStoredToken(token: string | null): void { try { if (token) localStorage.setItem(TOKEN_KEY, token); else localStorage.removeItem(TOKEN_KEY); @@ -32,7 +46,101 @@ function setStoredToken(token: string | null): void { } } +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function hexToBytes(hex: string): Uint8Array { + const clean = hex.length % 2 === 0 ? hex : "0" + hex; + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +async function derivePbkdf2Hex(password: string, saltHex: string): Promise { + if (typeof crypto !== "undefined" && crypto.subtle) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + enc.encode(password), + { name: "PBKDF2" }, + false, + ["deriveBits"], + ); + const saltBytes = hexToBytes(saltHex); + const bits = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + salt: saltBytes.buffer as ArrayBuffer, + iterations: 150_000, + hash: "SHA-256", + }, + keyMaterial, + 256, + ); + return bytesToHex(new Uint8Array(bits)); + } + // Fallback only if SubtleCrypto is unavailable in test env + return btoa(`${saltHex}:${password}`); +} + +function loadLocalAuthRecords(): LocalAuthRecord[] { + try { + const raw = localStorage.getItem(LOCAL_AUTH_KEY); + if (raw) { + const parsed = JSON.parse(raw) as LocalAuthRecord[]; + if (Array.isArray(parsed)) return parsed; + } + } catch { + /* ignore */ + } + return []; +} + +function saveLocalAuthRecords(records: LocalAuthRecord[]): void { + try { + localStorage.setItem(LOCAL_AUTH_KEY, JSON.stringify(records)); + localStorage.setItem( + LOCAL_USERS_KEY, + JSON.stringify(records.map((r) => r.user)), + ); + } catch { + /* ignore */ + } +} + +function getLocalSessionUser(): User | null { + try { + const userId = sessionStorage.getItem(LOCAL_SESSION_KEY) ?? localStorage.getItem(LOCAL_SESSION_KEY); + if (!userId) return null; + const rec = loadLocalAuthRecords().find((r) => r.user.id === userId && !r.user.disabled); + return rec?.user ?? null; + } catch { + return null; + } +} + +function setLocalSessionUser(user: User | null): void { + try { + if (user) { + sessionStorage.setItem(LOCAL_SESSION_KEY, user.id); + localStorage.setItem(LOCAL_SESSION_KEY, user.id); + } else { + sessionStorage.removeItem(LOCAL_SESSION_KEY); + localStorage.removeItem(LOCAL_SESSION_KEY); + } + } catch { + /* ignore */ + } +} + function loadLocalUsers(): User[] { + const fromAuth = loadLocalAuthRecords().map((r) => r.user); + if (fromAuth.length > 0) return fromAuth; try { const raw = localStorage.getItem(LOCAL_USERS_KEY); if (raw) { @@ -42,30 +150,7 @@ function loadLocalUsers(): User[] { } catch { /* ignore */ } - const initial: User[] = [ - { - id: createUlid(), - email: "admin@pubscript.local", - displayName: "Local Admin", - role: "admin", - disabled: false, - createdAt: new Date().toISOString(), - }, - { - id: createUlid(), - email: "mara@pubscript.local", - displayName: "Mara Vell", - role: "author", - disabled: false, - createdAt: new Date().toISOString(), - }, - ]; - try { - localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(initial)); - } catch { - /* ignore */ - } - return initial; + return []; } function saveLocalUsers(users: User[]): void { @@ -86,29 +171,15 @@ function loadLocalAudit(): AuditLogEntry[] { } catch { /* ignore */ } - const initial: AuditLogEntry[] = [ - { - id: createUlid(), - at: new Date().toISOString(), - userId: "admin@pubscript.local", - action: "admin.bootstrap", - detail: { email: "admin@pubscript.local" }, - }, - ]; - try { - localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(initial)); - } catch { - /* ignore */ - } - return initial; + return []; } -function appendLocalAudit(action: string, detail: Record): void { +function appendLocalAudit(action: string, detail: Record, userId = "admin"): void { const entries = loadLocalAudit(); entries.unshift({ id: createUlid(), at: new Date().toISOString(), - userId: "admin@pubscript.local", + userId, action, detail, }); @@ -119,35 +190,22 @@ function appendLocalAudit(action: string, detail: Record): void } } -async function bootstrapAdminSession(): Promise { - try { - const res = await fetch(`${BASE}/auth/bootstrap-admin`, { - method: "POST", - credentials: "include", - }); - const ct = res.headers.get("content-type") ?? ""; - if (!res.ok || !ct.includes("application/json")) return null; - const data = (await res.json()) as { token?: string }; - if (data.token) { - setStoredToken(data.token); - return data.token; - } - } catch { - /* server offline */ - } - return null; +function authHeaders(extra?: Record): Record { + const token = getStoredToken(); + return { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(extra ?? {}), + }; } -async function request(path: string, init?: RequestInit, retried = false): Promise { +async function request(path: string, init?: RequestInit): Promise { const { serverEnabled } = useSettings.getState(); if (!serverEnabled) { throw new Error("Server disabled in settings (Local Storage Mode)"); } - const token = getStoredToken(); const headers: Record = { ...(init?.body ? { "Content-Type": "application/json" } : {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...((init?.headers as Record | undefined) ?? {}), + ...authHeaders(init?.headers as Record | undefined), }; const res = await fetch(`${BASE}${path}`, { @@ -158,19 +216,13 @@ async function request(path: string, init?: RequestInit, retried = false): Pr const contentType = res.headers.get("content-type") ?? ""; - if (res.status === 401 && !retried) { - const newToken = await bootstrapAdminSession(); - if (newToken) { - return request(path, init, true); - } - } - if (!res.ok) { let detail = res.statusText; if (contentType.includes("application/json")) { try { - const body = (await res.json()) as { error?: string }; - if (body.error) detail = body.error; + const body = (await res.json()) as { error?: string; message?: string }; + if (body.message) detail = body.message; + else if (body.error) detail = body.error; } catch { /* ignore */ } @@ -188,7 +240,189 @@ async function request(path: string, init?: RequestInit, retried = false): Pr } export const api = { - // Admin + // ---- Auth & First-Install Setup ---- + authStatus: async (): Promise => { + try { + const res = await fetch(`${BASE}/auth/status`, { + credentials: "include", + headers: authHeaders(), + }); + const ct = res.headers.get("content-type") ?? ""; + if (res.ok && ct.includes("application/json")) { + const data = (await res.json()) as AuthStatusResponse; + return data; + } + } catch { + /* offline fallback */ + } + const records = loadLocalAuthRecords(); + const currentUser = getLocalSessionUser(); + return { + initialized: records.length > 0, + authenticated: Boolean(currentUser), + user: currentUser, + }; + }, + + setupAdmin: async (input: { + email: string; + displayName: string; + password: string; + }): Promise<{ token: string; user: User }> => { + const cleanEmail = input.email.trim().toLowerCase(); + const cleanName = input.displayName.trim(); + const salt = new Uint8Array(16); + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + crypto.getRandomValues(salt); + } + const saltHex = bytesToHex(salt); + const hashHex = await derivePbkdf2Hex(input.password, saltHex); + + try { + const res = await fetch(`${BASE}/auth/setup`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: cleanEmail, + displayName: cleanName, + password: input.password, + }), + }); + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + const data = (await res.json()) as { + token?: string; + user?: User; + error?: string; + message?: string; + }; + if (!res.ok) { + throw new Error(data.message ?? data.error ?? "Failed to set up administrator account"); + } + if (data.token && data.user) { + setStoredToken(data.token); + saveLocalAuthRecords([{ user: data.user, saltHex, hashHex }]); + setLocalSessionUser(data.user); + return { token: data.token, user: data.user }; + } + } + } catch (e) { + if (e instanceof Error && (e.message.includes("already_initialized") || e.message.includes("already been"))) { + throw e; + } + } + + // Offline local setup fallback + const existing = loadLocalAuthRecords(); + if (existing.length > 0) { + throw new Error("Administrator account has already been created. Please sign in."); + } + const localUser: User = { + id: createUlid(), + email: cleanEmail, + displayName: cleanName, + role: "admin", + disabled: false, + createdAt: new Date().toISOString(), + }; + const token = createUlid(); + setStoredToken(token); + saveLocalAuthRecords([{ user: localUser, saltHex, hashHex }]); + setLocalSessionUser(localUser); + appendLocalAudit("auth.setup", { email: cleanEmail }, localUser.id); + return { token, user: localUser }; + }, + + login: async (email: string, password: string): Promise<{ token: string; user: User }> => { + const cleanEmail = email.trim().toLowerCase(); + try { + const res = await fetch(`${BASE}/auth/login`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: cleanEmail, password }), + }); + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + const data = (await res.json()) as { + token?: string; + user?: User; + error?: string; + }; + if (res.status === 429) { + throw new Error("Too many sign-in attempts. Please wait a minute and try again."); + } + if (!res.ok || !data.token || !data.user) { + throw new Error("Invalid email or password."); + } + setStoredToken(data.token); + setLocalSessionUser(data.user); + return { token: data.token, user: data.user }; + } + } catch (e) { + if ( + e instanceof Error && + (e.message.includes("Invalid email") || e.message.includes("Too many sign-in")) + ) { + throw e; + } + } + + // Offline local verification fallback + const records = loadLocalAuthRecords(); + const record = records.find((r) => r.user.email.toLowerCase() === cleanEmail); + if (!record || record.user.disabled) { + throw new Error("Invalid email or password."); + } + const candidateHash = await derivePbkdf2Hex(password, record.saltHex); + if (candidateHash !== record.hashHex) { + appendLocalAudit("auth.login_failed", { email: cleanEmail }, record.user.id); + throw new Error("Invalid email or password."); + } + const token = createUlid(); + setStoredToken(token); + setLocalSessionUser(record.user); + appendLocalAudit("auth.login", { email: cleanEmail }, record.user.id); + return { token, user: record.user }; + }, + + logout: async (): Promise<{ ok: true }> => { + try { + await fetch(`${BASE}/auth/logout`, { + method: "POST", + credentials: "include", + headers: authHeaders(), + }); + } catch { + /* ignore */ + } + setStoredToken(null); + setLocalSessionUser(null); + return { ok: true }; + }, + + changePassword: async ( + currentPassword: string, + newPassword: string, + ): Promise<{ ok: true }> => { + const out = await request<{ ok: true; token?: string }>("/auth/change-password", { + method: "POST", + body: JSON.stringify({ currentPassword, newPassword }), + }); + if (out.token) setStoredToken(out.token); + return { ok: true }; + }, + + me: () => request<{ user: User | null }>("/auth/me"), + + register: (email: string, password: string, displayName: string) => + request<{ token: string; user: User }>("/auth/register", { + method: "POST", + body: JSON.stringify({ email, password, displayName }), + }), + + // ---- Admin ---- adminStats: async (): Promise => { try { return await request("/admin/stats"); @@ -237,15 +471,19 @@ export const api = { } const created: User = { id: createUlid(), - email: input.email, - displayName: input.displayName, + email: input.email.trim().toLowerCase(), + displayName: input.displayName.trim(), role: input.role, disabled: false, createdAt: new Date().toISOString(), }; users.unshift(created); saveLocalUsers(users); - appendLocalAudit("user_create", { targetId: created.id, email: created.email, role: created.role }); + appendLocalAudit("user_create", { + targetId: created.id, + email: created.email, + role: created.role, + }); return created; } }, @@ -350,23 +588,6 @@ export const api = { } }, - // Auth - me: () => request<{ user: User | null }>("/auth/me"), - - login: (email: string, password: string) => - request<{ token: string; user: User }>("/auth/login", { - method: "POST", - body: JSON.stringify({ email, password }), - }), - - register: (email: string, password: string, displayName: string) => - request<{ token: string; user: User }>("/auth/register", { - method: "POST", - body: JSON.stringify({ email, password, displayName }), - }), - - logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }), - getServerConfig: async (portHint?: number): Promise<{ online: boolean; enabled: boolean; @@ -386,7 +607,10 @@ export const api = { ]; for (const url of endpoints) { try { - const res = await fetch(url, { credentials: "include" }); + const res = await fetch(url, { + credentials: "include", + headers: authHeaders(), + }); const ct = res.headers.get("content-type") ?? ""; if (res.ok && ct.includes("application/json")) { const data = (await res.json()) as { @@ -463,7 +687,7 @@ export const api = { const res = await fetch(url, { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json" }, + headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(patch), }); const ct = res.headers.get("content-type") ?? ""; @@ -527,7 +751,10 @@ export const api = { ]; for (const url of endpoints) { try { - const res = await fetch(url, { credentials: "include" }); + const res = await fetch(url, { + credentials: "include", + headers: authHeaders(), + }); const ct = res.headers.get("content-type") ?? ""; if (res.ok && ct.includes("application/json")) { const data = (await res.json()) as { @@ -585,7 +812,7 @@ export const api = { const res = await fetch(url, { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json" }, + headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ directory: params.directory, fileName: params.fileName, diff --git a/apps/web/src/components/AuthGate.tsx b/apps/web/src/components/AuthGate.tsx new file mode 100644 index 0000000..5695de8 --- /dev/null +++ b/apps/web/src/components/AuthGate.tsx @@ -0,0 +1,404 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { User } from "@pubscript/core"; +import { Badge, Button, Input, toast } from "@pubscript/ui"; +import { api } from "../api.js"; + +interface AuthContextValue { + user: User | null; + logout: () => Promise; + refreshAuth: () => Promise; +} + +const AuthContext = createContext({ + user: null, + logout: async () => {}, + refreshAuth: async () => {}, +}); + +export function useAuth(): AuthContextValue { + return useContext(AuthContext); +} + +function scorePassword(pw: string): { score: number; label: string; color: string } { + if (!pw) return { score: 0, label: "Enter a password", color: "var(--ps-text-muted)" }; + let score = 0; + if (pw.length >= 8) score += 1; + if (pw.length >= 12) score += 1; + if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1; + if (/\d/.test(pw)) score += 1; + if (/[^a-zA-Z0-9]/.test(pw)) score += 1; + + if (pw.length < 8) { + return { score: 1, label: "Too short (minimum 8 characters)", color: "var(--ps-danger)" }; + } + if (score <= 2) { + return { score: 2, label: "Fair", color: "var(--ps-warning)" }; + } + if (score === 3) { + return { score: 3, label: "Good", color: "var(--ps-accent)" }; + } + return { score: 4, label: "Strong (Argon2id ready)", color: "var(--ps-success)" }; +} + +export function AuthGate({ children }: { children: React.ReactNode }) { + const [loading, setLoading] = useState(true); + const [initialized, setInitialized] = useState(true); + const [authenticated, setAuthenticated] = useState(false); + const [user, setUser] = useState(null); + + // Setup / Login form state + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const refreshAuth = useCallback(async () => { + try { + const status = await api.authStatus(); + setInitialized(status.initialized); + setAuthenticated(status.authenticated); + setUser(status.user); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refreshAuth(); + }, [refreshAuth]); + + const logout = useCallback(async () => { + await api.logout(); + setAuthenticated(false); + setUser(null); + setPassword(""); + toast.info("Signed out of PubScript"); + }, []); + + const strength = useMemo(() => scorePassword(password), [password]); + + const handleSetupSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const cleanName = displayName.trim(); + const cleanEmail = email.trim().toLowerCase(); + + if (!cleanName) { + setError("Please enter an administrator display name."); + return; + } + if (!cleanEmail || !cleanEmail.includes("@")) { + setError("Please enter a valid administrator email address."); + return; + } + if (password.length < 8) { + setError("Password must be at least 8 characters long."); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + + setSubmitting(true); + try { + const res = await api.setupAdmin({ + email: cleanEmail, + displayName: cleanName, + password, + }); + setInitialized(true); + setAuthenticated(true); + setUser(res.user); + setPassword(""); + setConfirmPassword(""); + toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create administrator account."); + } finally { + setSubmitting(false); + } + }; + + const handleLoginSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const cleanEmail = email.trim().toLowerCase(); + if (!cleanEmail || !password) { + setError("Please enter your email and password."); + return; + } + + setSubmitting(true); + try { + const res = await api.login(cleanEmail, password); + setAuthenticated(true); + setUser(res.user); + setPassword(""); + toast.success(`Signed in as ${res.user.displayName}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid email or password."); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( +
+ Verifying session security… +
+ ); + } + + if (!initialized || !authenticated) { + const isFirstInstall = !initialized; + return ( +
+
+
+ PubScript +
+ + {isFirstInstall ? "đź”’ First-Install Security Setup" : "đź”’ Authenticated Access Required"} + +
+

+ {isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"} +

+

+ {isFirstInstall + ? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access." + : "Enter your credentials to unlock your manuscripts and studio workspace."} +

+
+ + {error && ( +
+ {error} +
+ )} + + {isFirstInstall ? ( +
void handleSetupSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}> + setDisplayName(e.target.value)} + required + /> + setEmail(e.target.value)} + required + /> +
+
+ + +
+ setPassword(e.target.value)} + required + /> +
+
+ {[1, 2, 3, 4].map((step) => ( +
= step ? strength.color : "var(--ps-border)", + transition: "background 0.15s", + }} + /> + ))} +
+ + {strength.label} + +
+
+ + setConfirmPassword(e.target.value)} + required + /> + +
+ +
+ + ) : ( +
void handleLoginSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}> + setEmail(e.target.value)} + required + /> +
+
+ + +
+ setPassword(e.target.value)} + required + /> +
+ +
+ +
+
+ )} + +
+ Argon2id (64 MiB) · SHA-256 Session + HttpOnly · SameSite=Lax +
+
+
+ ); + } + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/Topbar.tsx b/apps/web/src/components/Topbar.tsx index 8466661..70f3e4b 100644 --- a/apps/web/src/components/Topbar.tsx +++ b/apps/web/src/components/Topbar.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef, useState, useEffect } from "react"; import { useLocation, NavLink, useNavigate } from "react-router-dom"; import { useSettings } from "../store/settings.js"; import { useProjectStore } from "../store/project-store.js"; +import { useAuth } from "./AuthGate.js"; import { Badge } from "@pubscript/ui"; import type { Entity } from "@pubscript/core"; @@ -30,6 +31,7 @@ function entityName(e: Entity): string { export function Topbar({ onOpenExport }: TopbarProps) { const location = useLocation(); const navigate = useNavigate(); + const { user, logout } = useAuth(); const autoSave = useSettings((s) => s.autoSave); const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); const setSettings = useSettings((s) => s.set); @@ -186,6 +188,23 @@ export function Topbar({ onOpenExport }: TopbarProps) { âš™ + + {user && ( +
+ + {`👤 ${user.displayName}`} + + +
+ )} ); } diff --git a/server/src/auth.ts b/server/src/auth.ts index c64a801..69385d5 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -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 => argon2.hash(pw, { type: argon2.argon2id }); -export const verifyPassword = (hash: string, pw: string): Promise => 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 => + argon2.hash(pw, ARGON2_OPTS); + +export const verifyPassword = (hash: string, pw: string): Promise => + argon2.verify(hash, pw); + +// Precomputed dummy hash to prevent timing-based user enumeration on login +let dummyHashPromise: Promise | null = null; +function getDummyHash(): Promise { + 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(); 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) }; }); } diff --git a/server/src/config.ts b/server/src/config.ts index 01acf56..87e75dd 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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"), diff --git a/server/src/index.ts b/server/src/index.ts index e72778e..3698ec5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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; 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; diff --git a/server/src/server.test.ts b/server/src/server.test.ts index ebcfb1f..01223e2 100644 --- a/server/src/server.test.ts +++ b/server/src/server.test.ts @@ -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(); + } + }); });