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:
@@ -24,26 +24,15 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
|
||||
// TEMPORARY hardware-bench escape hatch. When SETUP_AUTH_BYPASS=1, the setup
|
||||
// endpoints skip the admin guard so devices can be discovered/assigned before
|
||||
// the login flow exists. Remove once real admin login is wired.
|
||||
//
|
||||
// Hardened (flagged by security review): this can NEVER silently open auth in
|
||||
// a deployable config. It is honoured ONLY when all hold, else the server
|
||||
// FAILS CLOSED (throws) rather than running unauthenticated:
|
||||
// (a) NODE_ENV !== 'production'
|
||||
// (b) the listener is bound to loopback (HOST is 127.0.0.1 / ::1 / localhost)
|
||||
// See server.ts TODO + wiki/concepts/first-run-setup.md.
|
||||
const { guard: adminGuard, bypassed: authBypass } = resolveAdminGuard(app);
|
||||
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts).
|
||||
const adminGuard = requireRole("admin");
|
||||
|
||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||
// `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE).
|
||||
// `authBypass` tells the UI the setup endpoints aren't requiring a token
|
||||
// (testing only), so it can drop the admin-token requirement.
|
||||
app.get("/api/setup/catalog", async () => {
|
||||
const catalog = registry.catalog();
|
||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||
return { ...catalog, discoverable, authBypass };
|
||||
return { ...catalog, discoverable };
|
||||
});
|
||||
|
||||
// Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc).
|
||||
@@ -132,38 +121,3 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
|
||||
/**
|
||||
* Resolve the setup admin guard. Returns the real admin role guard unless the
|
||||
* SETUP_AUTH_BYPASS escape hatch is both requested AND safe; if it's requested
|
||||
* but unsafe, throws so the server fails closed instead of running open.
|
||||
* `bypassed` is surfaced to the UI so it can drop the admin-token requirement.
|
||||
*/
|
||||
function resolveAdminGuard(app: FastifyInstance): {
|
||||
guard: ReturnType<typeof requireRole>;
|
||||
bypassed: boolean;
|
||||
} {
|
||||
if (process.env.SETUP_AUTH_BYPASS !== "1") {
|
||||
return { guard: requireRole("admin"), bypassed: false };
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const isLoopback = LOOPBACK_HOSTS.has(host);
|
||||
|
||||
if (isProd || !isLoopback) {
|
||||
// Fail closed: never honour an auth bypass in production or on a non-loopback
|
||||
// listener (that would expose unauthenticated setup endpoints on the network).
|
||||
throw new Error(
|
||||
`SETUP_AUTH_BYPASS refused: requires NODE_ENV!=production (is "${process.env.NODE_ENV ?? "undefined"}") ` +
|
||||
`and a loopback HOST (is "${host}"). Set HOST=127.0.0.1 for local testing, or unset the bypass.`,
|
||||
);
|
||||
}
|
||||
|
||||
app.log.warn(
|
||||
`⚠️ SETUP_AUTH_BYPASS=1 — /api/setup/* admin auth DISABLED on ${host} (testing only)`,
|
||||
);
|
||||
return { guard: async () => {}, bypassed: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user