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:
2026-06-14 10:45:38 +02:00
parent 77606da2c9
commit 64d5e45f11
15 changed files with 490 additions and 138 deletions
+60
View File
@@ -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>
);
}