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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user