diff --git a/apps/server/.env.example b/apps/server/.env.example index 1cc4427..6ea3b2a 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -13,11 +13,8 @@ JWT_SECRET= # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # LOG_LEVEL=info # DATABASE_URL=./parking.sqlite +# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only) -# Testing-only ------------------------------------------------------------ -# Bypass the admin auth on /api/setup/* so you can discover/assign devices -# before the login flow exists. HARDENED: only honoured when NODE_ENV is not -# "production" AND HOST is loopback (127.0.0.1 / ::1 / localhost); otherwise -# the server refuses to start. Never set this in production. -# SETUP_AUTH_BYPASS=1 -# HOST=127.0.0.1 +# First admin (seed once): pnpm --filter @parking/server seed-admin +# ADMIN_USER=admin +# ADMIN_PASS= diff --git a/apps/server/package.json b/apps/server/package.json index 5c79737..171dee5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,10 +7,12 @@ "build": "tsc -b", "dev": "node --env-file-if-exists=.env --watch --experimental-strip-types src/index.ts", "start": "node --env-file-if-exists=.env dist/index.js", + "seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "11.2.0", "@fastify/jwt": "10.1.0", "@fastify/static": "9.1.3", diff --git a/apps/server/scripts/seed-admin.mjs b/apps/server/scripts/seed-admin.mjs new file mode 100644 index 0000000..e3814e0 --- /dev/null +++ b/apps/server/scripts/seed-admin.mjs @@ -0,0 +1,59 @@ +// Seed the first admin user (run once at install). +// +// ADMIN_USER=admin ADMIN_PASS='strong-pass' \ +// node --env-file-if-exists=.env apps/server/scripts/seed-admin.mjs +// +// Or interactively (prompts for a hidden password): +// node --env-file-if-exists=.env apps/server/scripts/seed-admin.mjs +// +// Idempotent-ish: refuses to overwrite an existing user unless FORCE=1. + +import { randomUUID } from "node:crypto"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const bcrypt = require("bcrypt"); +const { createDb, users, eq } = require("@parking/db"); + +const username = process.env.ADMIN_USER ?? process.argv[2]; +let password = process.env.ADMIN_PASS; + +if (!username) { + console.error("usage: ADMIN_USER=.. ADMIN_PASS=.. seed-admin.mjs (or pass a username arg)"); + process.exit(1); +} + +if (!password) { + const rl = createInterface({ input: stdin, output: stdout }); + password = (await rl.question(`Password for "${username}": `)).trim(); + rl.close(); +} +if (!password || password.length < 8) { + console.error("password must be at least 8 characters"); + process.exit(1); +} + +const db = createDb(); +const existing = await db.select().from(users).where(eq(users.username, username)).get(); +if (existing && process.env.FORCE !== "1") { + console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`); + process.exit(1); +} + +const passwordHash = await bcrypt.hash(password, 12); + +if (existing) { + await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id)); + console.log(`reset password for admin "${username}"`); +} else { + await db.insert(users).values({ + id: randomUUID(), + username, + passwordHash, + role: "admin", + }); + console.log(`created admin "${username}"`); +} +process.exit(0); diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 58329c8..9298228 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -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; 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 }); } diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts new file mode 100644 index 0000000..38b3a78 --- /dev/null +++ b/apps/server/src/routes/auth.ts @@ -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 { + 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 }; + }, + ); +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 03bb5a2..16db876 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -24,26 +24,15 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { 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 { }, ); } - -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; - 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 }; -} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 25242af..1afb4a0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 ({ 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; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0e4e387..20d46d4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,27 +1,48 @@ import { useEffect, useState } from "react"; +import { fetchMe, logout, type SessionUser } from "./api.js"; +import { Login } from "./Login.js"; import { SetupWizard } from "./SetupWizard.js"; // Operator UI shell. Plain React (no admin framework) — the operator UI is // simple enough that a framework's abstractions cost more than they save. -// See wiki/entities/react-vite-spa.md. +// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me. +// See wiki/entities/react-vite-spa.md and local-jwt-auth.md. export function App() { - const [health, setHealth] = useState("checking…"); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { - fetch("/health") - .then((r) => r.json()) - .then((d: { status: string }) => setHealth(d.status)) - .catch(() => setHealth("unreachable")); + fetchMe() + .then(setUser) + .finally(() => setLoading(false)); }, []); + if (loading) return

Loading…

; + if (!user) return ; + return (
-

Parking System

-

- API health: {health} -

- +
+

Parking System

+ + {user.username} ({user.role}){" "} + + +
+ {user.role === "admin" ? ( + + ) : ( +

Signed in. (Operator console coming soon.)

+ )}
); } diff --git a/apps/web/src/Login.tsx b/apps/web/src/Login.tsx new file mode 100644 index 0000000..21463af --- /dev/null +++ b/apps/web/src/Login.tsx @@ -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(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 ( +
+

Parking System

+
+
+ +
+
+ +
+ {error &&

{error}

} + +
+
+ ); +} diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index d3a5eb1..d098b04 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -11,11 +11,9 @@ import { // First-run setup wizard (scaffold). The admin picks a device per category for a // lane from the driver catalog and fills in its connection config. Drivers that // support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found -// devices; selecting one auto-fills the config. See wiki/concepts/first-run-setup.md +// devices; selecting one auto-fills the config. Auth is via the admin's session +// cookie (the SPA only renders this for admins). See wiki/concepts/first-run-setup.md // and device-discovery.md. -// -// NOTE: discovery + assign require an admin token. Wiring the real login flow is -// a follow-up; for now a token is read from a field so the scan can be exercised. const CATEGORIES: { key: DeviceCategory; title: string }[] = [ { key: "access", title: "Access controller" }, @@ -28,7 +26,6 @@ export function SetupWizard() { const [catalog, setCatalog] = useState(null); const [lane, setLane] = useState(1); const [picked, setPicked] = useState>>({}); - const [token, setToken] = useState(""); const [error, setError] = useState(null); useEffect(() => { @@ -52,22 +49,6 @@ export function SetupWizard() { style={{ width: "4rem" }} /> - {catalog.authBypass ? ( - - ⚠️ auth bypass on (testing) — no token needed - - ) : ( - - )} {CATEGORIES.map(({ key, title }) => ( @@ -76,8 +57,6 @@ export function SetupWizard() { title={title} entries={catalog[key]} discoverableIds={catalog.discoverable} - token={token} - authBypass={catalog.authBypass} selectedId={picked[key]} onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))} /> @@ -90,16 +69,12 @@ function CategoryPicker({ title, entries, discoverableIds, - token, - authBypass, selectedId, onSelect, }: { title: string; entries: CatalogEntry[]; discoverableIds: string[]; - token: string; - authBypass: boolean; selectedId: string | undefined; onSelect: (id: string) => void; }) { @@ -117,7 +92,7 @@ function CategoryPicker({ setScanning(true); setScanError(null); try { - setFound(await discoverDevices(token, selected.id)); + setFound(await discoverDevices(selected.id)); } catch (e) { setScanError((e as Error).message); } finally { @@ -153,12 +128,9 @@ function CategoryPicker({ {canDiscover && (
- - {!authBypass && !token && ( - enter an admin token to scan - )} {scanError && {scanError}} {found && found.length === 0 &&

No controllers found on the LAN.

} {found && found.length > 0 && ( diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 0a30a62..68c47d6 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,78 @@ // Thin API client for the operator/admin UI. +// +// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends +// automatically (credentials: 'include'). For mutations we echo the readable +// CSRF cookie back in the X-CSRF-Token header (double-submit). See +// wiki/entities/local-jwt-auth.md. + +const CSRF_COOKIE = "parking_csrf"; +const CSRF_HEADER = "X-CSRF-Token"; + +function readCookie(name: string): string | null { + const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return m ? decodeURIComponent(m[1]!) : null; +} + +/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */ +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? "GET").toUpperCase(); + const headers = new Headers(init.headers); + if (init.body && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + if (method !== "GET" && method !== "HEAD") { + const csrf = readCookie(CSRF_COOKIE); + if (csrf) headers.set(CSRF_HEADER, csrf); + } + const res = await fetch(path, { ...init, headers, credentials: "include" }); + if (!res.ok) { + const msg = (await res.json().catch(() => ({}))) as { error?: string }; + throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +// --- Auth ----------------------------------------------------------------- + +export type Role = "admin" | "operator" | "cashier" | "readonly"; +export interface SessionUser { + id: string; + username: string; + role: Role; +} + +export function login(username: string, password: string): Promise { + return apiFetch("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export function logout(): Promise<{ ok: boolean }> { + return apiFetch("/api/auth/logout", { method: "POST" }); +} + +/** Returns the current user, or null if not authenticated. */ +export async function fetchMe(): Promise { + try { + return await apiFetch("/api/auth/me"); + } catch (e) { + if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null; + throw e; + } +} + +// --- Device setup --------------------------------------------------------- export interface ConfigField { key: string; @@ -22,14 +96,10 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer"; export type Catalog = Record & { /** Driver ids that support LAN discovery. */ discoverable: string[]; - /** True when setup endpoints skip admin auth (testing only) — no token needed. */ - authBypass: boolean; }; -export async function fetchCatalog(): Promise { - const res = await fetch("/api/setup/catalog"); - if (!res.ok) throw new Error(`catalog: ${res.status}`); - return res.json() as Promise; +export function fetchCatalog(): Promise { + return apiFetch("/api/setup/catalog"); } export interface DiscoveredDevice { @@ -41,18 +111,10 @@ export interface DiscoveredDevice { } /** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */ -export async function discoverDevices( - token: string, - driverId: string, -): Promise { - const res = await fetch(`/api/setup/discover/${driverId}`, { - headers: { authorization: `Bearer ${token}` }, - }); - if (!res.ok) { - const msg = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(msg.error ?? `discover: ${res.status}`); - } - const body = (await res.json()) as { devices: DiscoveredDevice[] }; +export async function discoverDevices(driverId: string): Promise { + const body = await apiFetch<{ devices: DiscoveredDevice[] }>( + `/api/setup/discover/${driverId}`, + ); return body.devices; } @@ -63,15 +125,6 @@ export interface AssignBody { config: Record; } -export async function assignDevice(token: string, body: AssignBody): Promise { - const res = await fetch("/api/setup/assign", { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const msg = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(msg.error ?? `assign: ${res.status}`); - } - return res.json(); +export function assignDevice(body: AssignBody): Promise { + return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) }); } diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..d2bf0dd --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,61 @@ +# nginx reverse proxy for the parking system (production). +# +# Serves the built SPA (apps/web/dist) and proxies the API to the Fastify +# backend on 127.0.0.1:3000. Same-origin: the SPA and API share one origin, so +# the HttpOnly auth cookie and SameSite=Strict work without CORS. +# +# TLS terminates here. The backend runs with NODE_ENV=production, which makes +# the auth cookies Secure (HTTPS-only) — so this server MUST be served over +# https in production. A minimal http->https redirect block is included. +# +# Install: copy to /etc/nginx/sites-available/parking, symlink into +# sites-enabled, set server_name + cert paths, then `nginx -t && systemctl reload nginx`. + +upstream parking_backend { + server 127.0.0.1:3000; + keepalive 16; +} + +# Redirect http -> https. +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name parking.local; # <-- set to your hostname + + ssl_certificate /etc/ssl/parking/fullchain.pem; # <-- set + ssl_certificate_key /etc/ssl/parking/privkey.pem; # <-- set + ssl_protocols TLSv1.2 TLSv1.3; + + # Built SPA assets. + root /opt/parking/apps/web/dist; + index index.html; + + # API + health -> Fastify backend. + location ~ ^/(api|health) { + proxy_pass http://parking_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + # Cookies pass through unchanged (same-origin) — do not rewrite. + } + + # SPA fallback: every other path serves index.html (client-side routing). + location / { + try_files $uri $uri/ /index.html; + } + + # Long-cache hashed assets. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb778ca..f77c999 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: apps/server: dependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 '@fastify/cors': specifier: 11.2.0 version: 11.2.0 @@ -605,6 +608,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} @@ -1778,6 +1784,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.2 + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 diff --git a/wiki/entities/local-jwt-auth.md b/wiki/entities/local-jwt-auth.md index e658a9b..dfe1b5a 100644 --- a/wiki/entities/local-jwt-auth.md +++ b/wiki/entities/local-jwt-auth.md @@ -14,10 +14,27 @@ Authentication and authorization, kept **fully local** — a direct consequence - `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no insecure default — and mints tokens with an **8h expiry** (bound to a shift). -- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. +- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The + first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint). - Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier / readonly**. No Casbin or full RBAC engine needed at this scale. +## Cookie session (browser auth) + +The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and sets two cookies: + +- **`parking_token`** — the JWT, **HttpOnly + SameSite=Strict** (+ `Secure` when + `NODE_ENV=production`). JS can't read it; `@fastify/jwt` reads it from the cookie, not the + `Authorization` header. +- **`parking_csrf`** — a random token, **readable** by JS. The JWT also carries a matching `csrf` + claim. On every mutation the SPA echoes the cookie in the **`X-CSRF-Token`** header; the guard + requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt. + +Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev +[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API +**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only +`SETUP_AUTH_BYPASS` shim, now removed.) + > **Open decision:** moving from the symmetric secret to an **asymmetric key (RS256/EdDSA)** so > verifying hosts hold only a public key — [[open-questions]] #7. Relevant before any > multi-host/multi-lane deployment. diff --git a/wiki/log.md b/wiki/log.md index ac51dc9..97c6703 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -72,3 +72,14 @@ is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable aux input + PULL SDK but that's unverified and needs a new driver. Recorded in [[access-controller-button-flow]] + [[zkteco-controller]]. Entry-lane hardware decision paused to focus on the business side. + +## [2026-06-15] feature | Cookie-based auth/authz (login, CSRF) +Built real authentication: bcrypt login → JWT in an HttpOnly+SameSite=Strict +cookie, readable CSRF cookie + X-CSRF-Token header (double-submit) on mutations, +role-guarded routes. Routes: /api/auth/{login,logout,me}. First admin seeded via +`pnpm --filter @parking/server seed-admin`. Removed the SETUP_AUTH_BYPASS shim +and the wizard token field; the SPA gates on /api/auth/me and only shows setup to +admins. Same-origin via the Vite dev proxy and a new prod nginx config +(deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401, +login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401, +session persists across reload. Updated [[local-jwt-auth]].