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
+28 -25
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js"; import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
import { AuthGate } from "./components/AuthGate.js";
import { ExportDialog } from "./features/export/ExportDialog.js"; import { ExportDialog } from "./features/export/ExportDialog.js";
import { AISidebar } from "./features/ai/AISidebar.js"; import { AISidebar } from "./features/ai/AISidebar.js";
import { ToastViewport } from "@pubscript/ui"; import { ToastViewport } from "@pubscript/ui";
@@ -45,31 +46,33 @@ export default function App() {
}, []); }, []);
return ( return (
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}> <AuthGate>
<SplashScreen /> <div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
<Sidebar nav={NAV} /> <SplashScreen />
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> <Sidebar nav={NAV} />
<div className="ps-workspace-row"> <Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
<main className="ps-main"> <div className="ps-workspace-row">
<Routes> <main className="ps-main">
<Route path="/" element={<Write />} /> <Routes>
<Route path="/characters" element={<Characters />} /> <Route path="/" element={<Write />} />
<Route path="/plot" element={<Plot />} /> <Route path="/characters" element={<Characters />} />
<Route path="/timeline" element={<Timeline />} /> <Route path="/plot" element={<Plot />} />
<Route path="/whiteboard" element={<Whiteboard />} /> <Route path="/timeline" element={<Timeline />} />
<Route path="/ai" element={<AI />} /> <Route path="/whiteboard" element={<Whiteboard />} />
<Route path="/projects" element={<Projects />} /> <Route path="/ai" element={<AI />} />
<Route path="/settings" element={<Settings />} /> <Route path="/projects" element={<Projects />} />
<Route path="/admin" element={<Admin />} /> <Route path="/settings" element={<Settings />} />
<Route path="*" element={<Write />} /> <Route path="/admin" element={<Admin />} />
</Routes> <Route path="*" element={<Write />} />
</main> </Routes>
{aiSidebarOpen && <AISidebar />} </main>
</div> {aiSidebarOpen && <AISidebar />}
</div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} /> <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} /> <ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
<ToastViewport /> <ToastViewport />
</div> </div>
</AuthGate>
); );
} }
+327 -100
View File
@@ -2,8 +2,10 @@ import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript
import { useSettings } from "./store/settings.js"; import { useSettings } from "./store/settings.js";
const BASE = "/api"; const BASE = "/api";
const TOKEN_KEY = "pubscript.admin.token"; const TOKEN_KEY = "pubscript.auth.token.v1";
const LOCAL_USERS_KEY = "pubscript.local.admin.users.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"; const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1";
export interface AdminStats { export interface AdminStats {
@@ -15,7 +17,19 @@ export interface AdminStats {
disabledUsers: number; 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 { try {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY);
} catch { } catch {
@@ -23,7 +37,7 @@ function getStoredToken(): string | null {
} }
} }
function setStoredToken(token: string | null): void { export function setStoredToken(token: string | null): void {
try { try {
if (token) localStorage.setItem(TOKEN_KEY, token); if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY); 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<string> {
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[] { function loadLocalUsers(): User[] {
const fromAuth = loadLocalAuthRecords().map((r) => r.user);
if (fromAuth.length > 0) return fromAuth;
try { try {
const raw = localStorage.getItem(LOCAL_USERS_KEY); const raw = localStorage.getItem(LOCAL_USERS_KEY);
if (raw) { if (raw) {
@@ -42,30 +150,7 @@ function loadLocalUsers(): User[] {
} catch { } catch {
/* ignore */ /* ignore */
} }
const initial: User[] = [ return [];
{
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;
} }
function saveLocalUsers(users: User[]): void { function saveLocalUsers(users: User[]): void {
@@ -86,29 +171,15 @@ function loadLocalAudit(): AuditLogEntry[] {
} catch { } catch {
/* ignore */ /* ignore */
} }
const initial: AuditLogEntry[] = [ return [];
{
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;
} }
function appendLocalAudit(action: string, detail: Record<string, unknown>): void { function appendLocalAudit(action: string, detail: Record<string, unknown>, userId = "admin"): void {
const entries = loadLocalAudit(); const entries = loadLocalAudit();
entries.unshift({ entries.unshift({
id: createUlid(), id: createUlid(),
at: new Date().toISOString(), at: new Date().toISOString(),
userId: "admin@pubscript.local", userId,
action, action,
detail, detail,
}); });
@@ -119,35 +190,22 @@ function appendLocalAudit(action: string, detail: Record<string, unknown>): void
} }
} }
async function bootstrapAdminSession(): Promise<string | null> { function authHeaders(extra?: Record<string, string>): Record<string, string> {
try { const token = getStoredToken();
const res = await fetch(`${BASE}/auth/bootstrap-admin`, { return {
method: "POST", ...(token ? { Authorization: `Bearer ${token}` } : {}),
credentials: "include", ...(extra ?? {}),
}); };
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;
} }
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const { serverEnabled } = useSettings.getState(); const { serverEnabled } = useSettings.getState();
if (!serverEnabled) { if (!serverEnabled) {
throw new Error("Server disabled in settings (Local Storage Mode)"); throw new Error("Server disabled in settings (Local Storage Mode)");
} }
const token = getStoredToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
...(init?.body ? { "Content-Type": "application/json" } : {}), ...(init?.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...authHeaders(init?.headers as Record<string, string> | undefined),
...((init?.headers as Record<string, string> | undefined) ?? {}),
}; };
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
@@ -158,19 +216,13 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
const contentType = res.headers.get("content-type") ?? ""; const contentType = res.headers.get("content-type") ?? "";
if (res.status === 401 && !retried) {
const newToken = await bootstrapAdminSession();
if (newToken) {
return request<T>(path, init, true);
}
}
if (!res.ok) { if (!res.ok) {
let detail = res.statusText; let detail = res.statusText;
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
try { try {
const body = (await res.json()) as { error?: string }; const body = (await res.json()) as { error?: string; message?: string };
if (body.error) detail = body.error; if (body.message) detail = body.message;
else if (body.error) detail = body.error;
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -188,7 +240,189 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
} }
export const api = { export const api = {
// Admin // ---- Auth & First-Install Setup ----
authStatus: async (): Promise<AuthStatusResponse> => {
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<AdminStats> => { adminStats: async (): Promise<AdminStats> => {
try { try {
return await request<AdminStats>("/admin/stats"); return await request<AdminStats>("/admin/stats");
@@ -237,15 +471,19 @@ export const api = {
} }
const created: User = { const created: User = {
id: createUlid(), id: createUlid(),
email: input.email, email: input.email.trim().toLowerCase(),
displayName: input.displayName, displayName: input.displayName.trim(),
role: input.role, role: input.role,
disabled: false, disabled: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
users.unshift(created); users.unshift(created);
saveLocalUsers(users); 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; 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<{ getServerConfig: async (portHint?: number): Promise<{
online: boolean; online: boolean;
enabled: boolean; enabled: boolean;
@@ -386,7 +607,10 @@ export const api = {
]; ];
for (const url of endpoints) { for (const url of endpoints) {
try { try {
const res = await fetch(url, { credentials: "include" }); const res = await fetch(url, {
credentials: "include",
headers: authHeaders(),
});
const ct = res.headers.get("content-type") ?? ""; const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) { if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as { const data = (await res.json()) as {
@@ -463,7 +687,7 @@ export const api = {
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
const ct = res.headers.get("content-type") ?? ""; const ct = res.headers.get("content-type") ?? "";
@@ -527,7 +751,10 @@ export const api = {
]; ];
for (const url of endpoints) { for (const url of endpoints) {
try { try {
const res = await fetch(url, { credentials: "include" }); const res = await fetch(url, {
credentials: "include",
headers: authHeaders(),
});
const ct = res.headers.get("content-type") ?? ""; const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) { if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as { const data = (await res.json()) as {
@@ -585,7 +812,7 @@ export const api = {
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ body: JSON.stringify({
directory: params.directory, directory: params.directory,
fileName: params.fileName, fileName: params.fileName,
+404
View File
@@ -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<void>;
refreshAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue>({
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<User | null>(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<string | null>(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 (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--ps-bg)",
color: "var(--ps-text-muted)",
fontFamily: "var(--ps-font-ui)",
}}
>
Verifying session security…
</div>
);
}
if (!initialized || !authenticated) {
const isFirstInstall = !initialized;
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
background:
"radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg)), var(--ps-bg) 70%)",
color: "var(--ps-text)",
fontFamily: "var(--ps-font-ui)",
}}
>
<div
style={{
width: "min(440px, 100%)",
background: "var(--ps-surface)",
border: "1px solid var(--ps-border)",
borderRadius: 14,
padding: "28px 28px 24px",
boxShadow: "0 24px 64px rgba(0, 0, 0, 0.45)",
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", marginBottom: 20 }}>
<img
src="/logo.svg"
alt="PubScript"
style={{ width: 72, height: 72, objectFit: "contain", marginBottom: 10 }}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 8 }}>
<Badge variant={isFirstInstall ? "accent" : "success"}>
{isFirstInstall ? "🔒 First-Install Security Setup" : "🔒 Authenticated Access Required"}
</Badge>
</div>
<h1 style={{ margin: "0 0 6px", fontSize: 20, fontWeight: 700 }}>
{isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}
</h1>
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
{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."}
</p>
</div>
{error && (
<div
role="alert"
style={{
padding: "10px 12px",
marginBottom: 14,
borderRadius: 8,
border: "1px solid var(--ps-danger)",
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
color: "var(--ps-danger)",
fontSize: 13,
fontWeight: 500,
}}
>
{error}
</div>
)}
{isFirstInstall ? (
<form onSubmit={(e) => void handleSetupSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Input
label="Administrator Display Name"
placeholder="e.g. Schwitzz"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
/>
<Input
label="Administrator Email"
type="email"
placeholder="you@example.com"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-setup-pw">
Master Password (min 8 chars)
</label>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
style={{
border: "none",
background: "transparent",
color: "var(--ps-accent)",
fontSize: 11.5,
cursor: "pointer",
padding: 0,
}}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<input
id="ps-setup-pw"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Create a strong password…"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
<div style={{ display: "flex", gap: 4, flex: 1, marginRight: 10 }}>
{[1, 2, 3, 4].map((step) => (
<div
key={step}
style={{
flex: 1,
height: 4,
borderRadius: 2,
background:
strength.score >= step ? strength.color : "var(--ps-border)",
transition: "background 0.15s",
}}
/>
))}
</div>
<span style={{ fontSize: 11.5, color: strength.color, fontWeight: 600 }}>
{strength.label}
</span>
</div>
</div>
<Input
label="Confirm Master Password"
type={showPassword ? "text" : "password"}
placeholder="Re-enter password…"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
<div style={{ marginTop: 8 }}>
<Button
type="submit"
variant="primary"
disabled={submitting}
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
>
{submitting ? "Encrypting & Creating Admin…" : "Create Administrator Account"}
</Button>
</div>
</form>
) : (
<form onSubmit={(e) => void handleLoginSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Input
label="Email Address"
type="email"
placeholder="you@example.com"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-login-pw">
Password
</label>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
style={{
border: "none",
background: "transparent",
color: "var(--ps-accent)",
fontSize: 11.5,
cursor: "pointer",
padding: 0,
}}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<input
id="ps-login-pw"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Enter your password…"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div style={{ marginTop: 8 }}>
<Button
type="submit"
variant="primary"
disabled={submitting}
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
>
{submitting ? "Signing in…" : "Sign In"}
</Button>
</div>
</form>
)}
<div
style={{
marginTop: 18,
paddingTop: 12,
borderTop: "1px solid var(--ps-border)",
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--ps-text-muted)",
}}
>
<span>Argon2id (64 MiB) · SHA-256 Session</span>
<span>HttpOnly · SameSite=Lax</span>
</div>
</div>
</div>
);
}
return (
<AuthContext.Provider value={{ user, logout, refreshAuth }}>
{children}
</AuthContext.Provider>
);
}
+19
View File
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation, NavLink, useNavigate } from "react-router-dom"; import { useLocation, NavLink, useNavigate } from "react-router-dom";
import { useSettings } from "../store/settings.js"; import { useSettings } from "../store/settings.js";
import { useProjectStore } from "../store/project-store.js"; import { useProjectStore } from "../store/project-store.js";
import { useAuth } from "./AuthGate.js";
import { Badge } from "@pubscript/ui"; import { Badge } from "@pubscript/ui";
import type { Entity } from "@pubscript/core"; import type { Entity } from "@pubscript/core";
@@ -30,6 +31,7 @@ function entityName(e: Entity): string {
export function Topbar({ onOpenExport }: TopbarProps) { export function Topbar({ onOpenExport }: TopbarProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth();
const autoSave = useSettings((s) => s.autoSave); const autoSave = useSettings((s) => s.autoSave);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const setSettings = useSettings((s) => s.set); const setSettings = useSettings((s) => s.set);
@@ -186,6 +188,23 @@ export function Topbar({ onOpenExport }: TopbarProps) {
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings"> <NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
⚙ ⚙
</NavLink> </NavLink>
{user && (
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 4 }}>
<Badge variant="neutral" title={`${user.email} (${user.role})`}>
{`👤 ${user.displayName}`}
</Badge>
<button
type="button"
className="ps-nav-item"
onClick={() => void logout()}
style={{ width: "auto", padding: "5px 9px", fontSize: 12.5 }}
title="Sign out of PubScript"
>
Sign out
</button>
</div>
)}
</header> </header>
); );
} }
+215 -42
View File
@@ -10,11 +10,30 @@ import { toUser, type UserRow } from "./mapping.js";
const SESSION_COOKIE = "ps_session"; const SESSION_COOKIE = "ps_session";
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
// ---- password hashing ---- // ---- password hashing (OWASP Argon2id parameters) ----
export const hashPassword = (pw: string): Promise<string> => argon2.hash(pw, { type: argon2.argon2id }); const ARGON2_OPTS: argon2.Options = {
export const verifyPassword = (hash: string, pw: string): Promise<boolean> => argon2.verify(hash, pw); 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 { export function newToken(): string {
return crypto.randomBytes(32).toString("hex"); return crypto.randomBytes(32).toString("hex");
} }
@@ -25,11 +44,16 @@ function tokenHash(token: string): string {
export function createSession(db: Db, userId: string): string { export function createSession(db: Db, userId: string): string {
const token = newToken(); const token = newToken();
const now = Date.now(); 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), tokenHash(token),
userId, userId,
new Date(now + SESSION_TTL_MS).toISOString(), new Date(now + SESSION_TTL_MS).toISOString(),
new Date(now).toISOString(), nowIso,
); );
return token; return token;
} }
@@ -51,7 +75,7 @@ export function userFromToken(db: Db, token: string | null | undefined): UserRow
return row ?? null; return row ?? null;
} }
// ---- tiny in-memory fixed-window rate limiter ---- // ---- fixed-window rate limiter ----
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean { export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
const hits = new Map<string, { count: number; resetAt: number }>(); const hits = new Map<string, { count: number; resetAt: number }>();
return (key: string): boolean => { return (key: string): boolean => {
@@ -74,19 +98,48 @@ export function bearerToken(req: FastifyRequest): string | null {
} }
export function registerAuthHook(app: FastifyInstance): void { 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) => { app.addHook("onRequest", async (req) => {
const db = app.db; 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); const row = userFromToken(db, token);
req.user = row ? toUser(row) : null; 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, { reply.setCookie(SESSION_COOKIE, token, {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure: req ? isSecureRequest(req) : false,
maxAge: SESSION_TTL_MS / 1000, maxAge: SESSION_TTL_MS / 1000,
}); });
} }
@@ -100,9 +153,16 @@ export function requireAuth(req: FastifyRequest): void {
} }
export function requireAdmin(req: FastifyRequest): void { export function requireAdmin(req: FastifyRequest): void {
requireAuth(req); 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 const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
| { owner_id: string } | { owner_id: string }
| undefined; | undefined;
@@ -116,24 +176,85 @@ export function canAccessProject(db: Db, user: User, projectId: string, needWrit
} }
// ---- auth routes ---- // ---- auth routes ----
const registerSchema = z.object({ const setupSchema = z.object({
email: z.string().email(), email: z.string().trim().toLowerCase().email(),
password: z.string().min(8), password: z.string().min(8).max(256),
displayName: z.string().min(1).max(80), 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 { export function registerAuthRoutes(app: FastifyInstance): void {
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
app.post("/api/auth/register", async (req, reply) => { // Check whether the instance has been initialized with an admin user and whether the caller is authenticated
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" }); app.get("/api/auth/status", async (req) => {
const body = registerSchema.parse(req.body); 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 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); const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
if (existing) throw app.httpErrors.create(409, { error: "email_taken" }); 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 role = isFirst ? "admin" : "author";
const id = ulid(); const id = ulid();
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -141,49 +262,107 @@ export function registerAuthRoutes(app: FastifyInstance): void {
db.prepare( db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)", "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
).run(id, body.email, passHash, body.displayName, role, now); ).run(id, body.email, passHash, body.displayName, role, now);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run( db.prepare(
ulid(), "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)",
now, ).run(ulid(), now, id, JSON.stringify({ email: body.email, first: isFirst }));
id,
JSON.stringify({ email: body.email, first: isFirst }),
);
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow; const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
const token = createSession(db, id); const token = createSession(db, id);
setSessionCookie(reply, token); setSessionCookie(reply, token, req);
return { token, user: toUser(row) }; return { token, user: toUser(row) };
}); });
app.post("/api/auth/login", async (req, reply) => { 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 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 db = app.db;
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined; const row = db
if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) { .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" }); 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); const token = createSession(db, row.id);
setSessionCookie(reply, token); setSessionCookie(reply, token, req);
return { token, user: toUser(row) }; return { token, user: toUser(row) };
}); });
app.post("/api/auth/logout", async (req, reply) => { 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); if (token) destroySession(app.db, token);
clearSessionCookie(reply); clearSessionCookie(reply);
return { ok: true }; 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) => { app.get("/api/auth/me", async (req) => {
requireAuth(req); requireAuth(req);
return { user: req.user }; return { user: req.user };
}); });
// Bootstrap or sign into the local admin account when no admin session is active yet. // Test-only bootstrap helper (disabled in production once initialized)
app.post("/api/auth/bootstrap-admin", async (_req, reply) => { 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; const db = app.db;
let adminRow = 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; .get() as UserRow | undefined;
if (!adminRow) { if (!adminRow) {
@@ -195,17 +374,11 @@ export function registerAuthRoutes(app: FastifyInstance): void {
db.prepare( db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)", "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
).run(id, email, passHash, "Local Admin", now); ).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; adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
} }
const token = createSession(db, adminRow.id); const token = createSession(db, adminRow.id);
setSessionCookie(reply, token); setSessionCookie(reply, token, req);
return { token, user: toUser(adminRow) }; return { token, user: toUser(adminRow) };
}); });
} }
+6 -1
View File
@@ -33,7 +33,12 @@ export interface ServerConfig {
} }
export const config: 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), port: envInt("PORT", 8080),
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"), origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"), 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 { ZodError } from "zod";
import { config } from "./config.js"; import { config } from "./config.js";
import { openDb, ensureAdmin } from "./db.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 { registerRoutes } from "./routes.js";
import { registerWs } from "./ws.js"; import { registerWs } from "./ws.js";
import { registerAdminRoutes } from "./admin.js"; import { registerAdminRoutes } from "./admin.js";
@@ -201,6 +207,11 @@ export async function buildApp() {
} }
app.addHook("onRequest", async (req, reply) => { 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 cfg = loadPersistedServerSysConfig();
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host; const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) { 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(); const persisted = loadPersistedServerSysConfig();
return { return {
ok: true, ok: true,
@@ -342,6 +354,7 @@ export async function buildApp() {
}); });
app.post("/api/system/server-config", async (req, reply) => { app.post("/api/system/server-config", async (req, reply) => {
requireAdmin(req);
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>; const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
const current = loadPersistedServerSysConfig(); const current = loadPersistedServerSysConfig();
const nextPort = const nextPort =
@@ -397,6 +410,7 @@ export async function buildApp() {
}); });
app.get("/api/system/list-dirs", async (req) => { app.get("/api/system/list-dirs", async (req) => {
requireAuth(req);
const query = (req.query ?? {}) as { path?: string }; const query = (req.query ?? {}) as { path?: string };
const rawTarget = query.path ? expandTilde(query.path) : homedir(); const rawTarget = query.path ? expandTilde(query.path) : homedir();
const target = existsSync(rawTarget) ? rawTarget : homedir(); const target = existsSync(rawTarget) ? rawTarget : homedir();
@@ -429,6 +443,7 @@ export async function buildApp() {
}); });
app.post("/api/system/save-export", async (req, reply) => { app.post("/api/system/save-export", async (req, reply) => {
requireAuth(req);
const body = (req.body ?? {}) as { const body = (req.body ?? {}) as {
directory?: string; directory?: string;
fileName?: string; fileName?: string;
+76
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach } from "vitest"; import { describe, it, expect, afterEach } from "vitest";
process.env.PUBSCRIPT_DB = ":memory:";
import { buildApp } from "./index.js"; import { buildApp } from "./index.js";
afterEach(async () => { afterEach(async () => {
@@ -96,4 +97,79 @@ describe("admin endpoints", () => {
await app.close(); 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();
}
});
}); });