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
+59
View File
@@ -0,0 +1,59 @@
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db";
import {
TOKEN_TTL,
clearAuthCookies,
newCsrfToken,
requireRole,
setAuthCookies,
} from "../auth.js";
// Local auth: username + bcrypt password → signed JWT in an HttpOnly cookie.
// Fully offline; no external identity provider. See wiki/entities/local-jwt-auth.md.
interface LoginBody {
username: string;
password: string;
}
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {};
if (!username || !password) {
return reply.code(400).send({ error: "username and password required" });
}
const user = await db.select().from(users).where(eq(users.username, username)).get();
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const ok = await bcrypt.compare(password, hash);
if (!user || !ok) {
return reply.code(401).send({ error: "invalid credentials" });
}
const csrf = newCsrfToken();
const token = await reply.jwtSign(
{ sub: user.id, username: user.username, role: user.role, csrf },
{ expiresIn: TOKEN_TTL },
);
setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role };
});
app.post("/api/auth/logout", async (_req, reply) => {
clearAuthCookies(reply);
return { ok: true };
});
// Who am I — used by the SPA to bootstrap session state on load.
app.get(
"/api/auth/me",
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
async (req) => {
const { sub, username, role } = req.user;
return { id: sub, username, role };
},
);
}