feat(security): require first-install Admin setup and authenticated login with Argon2id + HttpOnly SHA-256 sessions
This commit is contained in:
@@ -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,6 +46,7 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AuthGate>
|
||||||
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
|
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
|
||||||
<SplashScreen />
|
<SplashScreen />
|
||||||
<Sidebar nav={NAV} />
|
<Sidebar nav={NAV} />
|
||||||
@@ -71,5 +73,6 @@ export default function App() {
|
|||||||
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
</div>
|
</div>
|
||||||
|
</AuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+327
-100
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user