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:
+32
-11
@@ -1,27 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||
import { Login } from "./Login.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
|
||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||
// simple enough that a framework's abstractions cost more than they save.
|
||||
// See wiki/entities/react-vite-spa.md.
|
||||
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
|
||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||
|
||||
export function App() {
|
||||
const [health, setHealth] = useState<string>("checking…");
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/health")
|
||||
.then((r) => r.json())
|
||||
.then((d: { status: string }) => setHealth(d.status))
|
||||
.catch(() => setHealth("unreachable"));
|
||||
fetchMe()
|
||||
.then(setUser)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
||||
if (!user) return <Login onLoggedIn={setUser} />;
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
||||
<h1>Parking System</h1>
|
||||
<p>
|
||||
API health: <strong>{health}</strong>
|
||||
</p>
|
||||
<SetupWizard />
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<h1 style={{ margin: 0 }}>Parking System</h1>
|
||||
<span style={{ color: "#555" }}>
|
||||
{user.username} ({user.role}){" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
{user.role === "admin" ? (
|
||||
<SetupWizard />
|
||||
) : (
|
||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { login, type SessionUser } from "./api.js";
|
||||
|
||||
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onLoggedIn(await login(username, password));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
||||
<h1>Parking System</h1>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Username
|
||||
<br />
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Password
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<button type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
||||
// lane from the driver catalog and fills in its connection config. Drivers that
|
||||
// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found
|
||||
// devices; selecting one auto-fills the config. See wiki/concepts/first-run-setup.md
|
||||
// devices; selecting one auto-fills the config. Auth is via the admin's session
|
||||
// cookie (the SPA only renders this for admins). See wiki/concepts/first-run-setup.md
|
||||
// and device-discovery.md.
|
||||
//
|
||||
// NOTE: discovery + assign require an admin token. Wiring the real login flow is
|
||||
// a follow-up; for now a token is read from a field so the scan can be exercised.
|
||||
|
||||
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
|
||||
{ key: "access", title: "Access controller" },
|
||||
@@ -28,7 +26,6 @@ export function SetupWizard() {
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [lane, setLane] = useState(1);
|
||||
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,22 +49,6 @@ export function SetupWizard() {
|
||||
style={{ width: "4rem" }}
|
||||
/>
|
||||
</label>
|
||||
{catalog.authBypass ? (
|
||||
<span style={{ flex: 1, color: "#92400e" }}>
|
||||
⚠️ auth bypass on (testing) — no token needed
|
||||
</span>
|
||||
) : (
|
||||
<label style={{ flex: 1 }}>
|
||||
Admin token{" "}
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="needed to scan / assign"
|
||||
style={{ width: "60%" }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{CATEGORIES.map(({ key, title }) => (
|
||||
@@ -76,8 +57,6 @@ export function SetupWizard() {
|
||||
title={title}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
token={token}
|
||||
authBypass={catalog.authBypass}
|
||||
selectedId={picked[key]}
|
||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||||
/>
|
||||
@@ -90,16 +69,12 @@ function CategoryPicker({
|
||||
title,
|
||||
entries,
|
||||
discoverableIds,
|
||||
token,
|
||||
authBypass,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
token: string;
|
||||
authBypass: boolean;
|
||||
selectedId: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
@@ -117,7 +92,7 @@ function CategoryPicker({
|
||||
setScanning(true);
|
||||
setScanError(null);
|
||||
try {
|
||||
setFound(await discoverDevices(token, selected.id));
|
||||
setFound(await discoverDevices(selected.id));
|
||||
} catch (e) {
|
||||
setScanError((e as Error).message);
|
||||
} finally {
|
||||
@@ -153,12 +128,9 @@ function CategoryPicker({
|
||||
|
||||
{canDiscover && (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<button type="button" onClick={scan} disabled={scanning || (!authBypass && !token)}>
|
||||
<button type="button" onClick={scan} disabled={scanning}>
|
||||
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||
</button>
|
||||
{!authBypass && !token && (
|
||||
<span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>
|
||||
)}
|
||||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||
{found && found.length > 0 && (
|
||||
|
||||
+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