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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user