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