Cookie-based auth/authz with CSRF; remove auth bypass
Replace the dev-only token shim with real authentication. Backend: - @fastify/cookie; JWT carried in an HttpOnly + SameSite=Strict cookie (parking_token), read from the cookie not the Authorization header. - Double-submit CSRF: readable parking_csrf cookie + X-CSRF-Token header, both cross-checked against a csrf claim baked into the JWT; enforced on mutations. - Routes: POST /api/auth/login (bcrypt, constant-time-ish), POST logout, GET me. requireRole now verifies the cookie + CSRF + role. - seed-admin script (pnpm --filter @parking/server seed-admin) for the first admin; no bootstrap endpoint. - Removed SETUP_AUTH_BYPASS and catalog.authBypass entirely; setup endpoints use the cookie admin guard like everything else. Frontend: - apiFetch wrapper: credentials:'include' + X-CSRF-Token on mutations. - Login form; App gates on /api/auth/me and only shows setup to admins; logout. - Wizard token field removed (auth is the session cookie). Deploy: - deploy/nginx.conf: prod reverse proxy, SPA + /api same-origin, TLS, so the Secure cookies work. Dev stays same-origin via the Vite proxy. Verified (curl + browser): wrong pass -> 401; login sets cookies; me -> admin; assign without CSRF -> 403, with -> 201; no cookie -> 401; session persists across reload. wiki/local-jwt-auth updated.
This commit is contained in:
+82
-29
@@ -1,4 +1,78 @@
|
||||
// Thin API client for the operator/admin UI.
|
||||
//
|
||||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
||||
// automatically (credentials: 'include'). For mutations we echo the readable
|
||||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return m ? decodeURIComponent(m[1]!) : null;
|
||||
}
|
||||
|
||||
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? "GET").toUpperCase();
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||||
}
|
||||
const res = await fetch(path, { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth -----------------------------------------------------------------
|
||||
|
||||
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<SessionUser> {
|
||||
return apiFetch<SessionUser>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ ok: boolean }> {
|
||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Returns the current user, or null if not authenticated. */
|
||||
export async function fetchMe(): Promise<SessionUser | null> {
|
||||
try {
|
||||
return await apiFetch<SessionUser>("/api/auth/me");
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Device setup ---------------------------------------------------------
|
||||
|
||||
export interface ConfigField {
|
||||
key: string;
|
||||
@@ -22,14 +96,10 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||
/** Driver ids that support LAN discovery. */
|
||||
discoverable: string[];
|
||||
/** True when setup endpoints skip admin auth (testing only) — no token needed. */
|
||||
authBypass: boolean;
|
||||
};
|
||||
|
||||
export async function fetchCatalog(): Promise<Catalog> {
|
||||
const res = await fetch("/api/setup/catalog");
|
||||
if (!res.ok) throw new Error(`catalog: ${res.status}`);
|
||||
return res.json() as Promise<Catalog>;
|
||||
export function fetchCatalog(): Promise<Catalog> {
|
||||
return apiFetch<Catalog>("/api/setup/catalog");
|
||||
}
|
||||
|
||||
export interface DiscoveredDevice {
|
||||
@@ -41,18 +111,10 @@ export interface DiscoveredDevice {
|
||||
}
|
||||
|
||||
/** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */
|
||||
export async function discoverDevices(
|
||||
token: string,
|
||||
driverId: string,
|
||||
): Promise<DiscoveredDevice[]> {
|
||||
const res = await fetch(`/api/setup/discover/${driverId}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(msg.error ?? `discover: ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { devices: DiscoveredDevice[] };
|
||||
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
||||
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
||||
`/api/setup/discover/${driverId}`,
|
||||
);
|
||||
return body.devices;
|
||||
}
|
||||
|
||||
@@ -63,15 +125,6 @@ export interface AssignBody {
|
||||
config: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export async function assignDevice(token: string, body: AssignBody): Promise<unknown> {
|
||||
const res = await fetch("/api/setup/assign", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(msg.error ?? `assign: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
export function assignDevice(body: AssignBody): Promise<unknown> {
|
||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user