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:
+73
-7
@@ -1,15 +1,27 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Role } from "@parking/shared";
|
||||
|
||||
// Local JWT auth helpers — fully local, no external identity provider
|
||||
// (offline-first). See wiki/entities/local-jwt-auth.md.
|
||||
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
|
||||
// a separate readable CSRF cookie + matching header defends mutations
|
||||
// (double-submit). See wiki/entities/local-jwt-auth.md.
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: string; username: string; role: Role };
|
||||
user: { sub: string; username: string; role: Role };
|
||||
payload: { sub: string; username: string; role: Role; csrf: string };
|
||||
user: { sub: string; username: string; role: Role; csrf: string };
|
||||
}
|
||||
}
|
||||
|
||||
export const TOKEN_COOKIE = "parking_token";
|
||||
export const CSRF_COOKIE = "parking_csrf";
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
/** Token lifetime, also used as the cookie maxAge. */
|
||||
export const TOKEN_TTL = "8h";
|
||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||
* There is deliberately no fallback default — a missing, short, or placeholder
|
||||
@@ -26,13 +38,67 @@ export function requireJwtSecret(): string {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Cookies are secure in production; relaxed for local http dev. */
|
||||
function secureCookies(): boolean {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
export function newCsrfToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/** Set the auth (HttpOnly) + CSRF (readable) cookies after a successful login. */
|
||||
export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string): void {
|
||||
const secure = secureCookies();
|
||||
reply.setCookie(TOKEN_COOKIE, jwt, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
});
|
||||
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||
httpOnly: false,
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAuthCookies(reply: FastifyReply): void {
|
||||
reply.clearCookie(TOKEN_COOKIE, { path: "/" });
|
||||
reply.clearCookie(CSRF_COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
/**
|
||||
* preHandler role guard. Authorization is a simple per-route role check — no
|
||||
* Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md.
|
||||
* Double-submit CSRF check: the X-CSRF-Token header must match the CSRF cookie.
|
||||
* The CSRF token is bound into the JWT at login, so a stolen/forged cookie pair
|
||||
* still can't pass unless it matches the signed token. Only enforced on
|
||||
* state-changing methods (safe reads are exempt).
|
||||
*/
|
||||
function assertCsrf(req: FastifyRequest): void {
|
||||
if (!MUTATING.has(req.method)) return;
|
||||
const header = req.headers[CSRF_HEADER];
|
||||
const cookie = req.cookies[CSRF_COOKIE];
|
||||
const tokenCsrf = (req.user as { csrf?: string } | undefined)?.csrf;
|
||||
if (!header || !cookie || header !== cookie || (tokenCsrf && header !== tokenCsrf)) {
|
||||
throw Object.assign(new Error("invalid CSRF token"), { statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces
|
||||
* CSRF on mutations, then checks the role. Authorization is a simple per-route
|
||||
* role check — no Casbin/RBAC engine needed at this scale.
|
||||
*/
|
||||
export function requireRole(...allowed: Role[]) {
|
||||
return async (req: { jwtVerify: () => Promise<void>; user?: { role: Role } }) => {
|
||||
await req.jwtVerify();
|
||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||
assertCsrf(req);
|
||||
if (!req.user || !allowed.includes(req.user.role)) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { createDb, type Db } from "@parking/db";
|
||||
import { requireJwtSecret } from "./auth.js";
|
||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
|
||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||
@@ -19,22 +21,29 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
|
||||
const db = opts.db ?? createDb();
|
||||
|
||||
await app.register(cookie);
|
||||
|
||||
// Local JWT signing with a local secret — no external identity provider.
|
||||
// Fail fast rather than fall back to a known default: a booth machine started
|
||||
// without a real secret would sign tokens anyone could forge (incl. an admin
|
||||
// token), defeating the whole local-auth/anti-fraud model. No insecure default.
|
||||
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
||||
await app.register(jwt, {
|
||||
secret: requireJwtSecret(),
|
||||
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
|
||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||
await authRoutes(app, db);
|
||||
|
||||
// Device-agnostic setup: the admin selects devices per lane from the driver
|
||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
||||
await setupRoutes(app, db);
|
||||
|
||||
// TODO: device-driver runtime plugins, append-only event-log routes, login.
|
||||
// TODO: device-driver runtime plugins, append-only event-log routes.
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user