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:
@@ -13,11 +13,8 @@ JWT_SECRET=
|
|||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
# DATABASE_URL=./parking.sqlite
|
# DATABASE_URL=./parking.sqlite
|
||||||
|
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only)
|
||||||
|
|
||||||
# Testing-only ------------------------------------------------------------
|
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||||
# Bypass the admin auth on /api/setup/* so you can discover/assign devices
|
# ADMIN_USER=admin
|
||||||
# before the login flow exists. HARDENED: only honoured when NODE_ENV is not
|
# ADMIN_PASS=
|
||||||
# "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
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
"dev": "node --env-file-if-exists=.env --watch --experimental-strip-types src/index.ts",
|
"dev": "node --env-file-if-exists=.env --watch --experimental-strip-types src/index.ts",
|
||||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "11.2.0",
|
"@fastify/cors": "11.2.0",
|
||||||
"@fastify/jwt": "10.1.0",
|
"@fastify/jwt": "10.1.0",
|
||||||
"@fastify/static": "9.1.3",
|
"@fastify/static": "9.1.3",
|
||||||
|
|||||||
@@ -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 <username>
|
||||||
|
//
|
||||||
|
// 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);
|
||||||
+73
-7
@@ -1,15 +1,27 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import type { Role } from "@parking/shared";
|
import type { Role } from "@parking/shared";
|
||||||
|
|
||||||
// Local JWT auth helpers — fully local, no external identity provider
|
// 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" {
|
declare module "@fastify/jwt" {
|
||||||
interface FastifyJWT {
|
interface FastifyJWT {
|
||||||
payload: { sub: string; username: string; role: Role };
|
payload: { sub: string; username: string; role: Role; csrf: string };
|
||||||
user: { sub: string; username: string; role: Role };
|
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.
|
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||||
* There is deliberately no fallback default — a missing, short, or placeholder
|
* There is deliberately no fallback default — a missing, short, or placeholder
|
||||||
@@ -26,13 +38,67 @@ export function requireJwtSecret(): string {
|
|||||||
return secret;
|
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
|
* Double-submit CSRF check: the X-CSRF-Token header must match the CSRF cookie.
|
||||||
* Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md.
|
* 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[]) {
|
export function requireRole(...allowed: Role[]) {
|
||||||
return async (req: { jwtVerify: () => Promise<void>; user?: { role: Role } }) => {
|
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||||
await req.jwtVerify();
|
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||||
|
assertCsrf(req);
|
||||||
if (!req.user || !allowed.includes(req.user.role)) {
|
if (!req.user || !allowed.includes(req.user.role)) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
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();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
|
|
||||||
// TEMPORARY hardware-bench escape hatch. When SETUP_AUTH_BYPASS=1, the setup
|
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts).
|
||||||
// endpoints skip the admin guard so devices can be discovered/assigned before
|
const adminGuard = requireRole("admin");
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||||
// `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE).
|
// `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 () => {
|
app.get("/api/setup/catalog", async () => {
|
||||||
const catalog = registry.catalog();
|
const catalog = registry.catalog();
|
||||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
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).
|
// 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 jwt from "@fastify/jwt";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { createDb, type Db } from "@parking/db";
|
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";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
|
||||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
// 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();
|
const db = opts.db ?? createDb();
|
||||||
|
|
||||||
|
await app.register(cookie);
|
||||||
|
|
||||||
// Local JWT signing with a local secret — no external identity provider.
|
// 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
|
// 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
|
// 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.
|
// 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, {
|
await app.register(jwt, {
|
||||||
secret: requireJwtSecret(),
|
secret: requireJwtSecret(),
|
||||||
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
||||||
|
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
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
|
// Device-agnostic setup: the admin selects devices per lane from the driver
|
||||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
||||||
await setupRoutes(app, db);
|
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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-11
@@ -1,27 +1,48 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||||
|
import { Login } from "./Login.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||||
// simple enough that a framework's abstractions cost more than they save.
|
// 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() {
|
export function App() {
|
||||||
const [health, setHealth] = useState<string>("checking…");
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/health")
|
fetchMe()
|
||||||
.then((r) => r.json())
|
.then(setUser)
|
||||||
.then((d: { status: string }) => setHealth(d.status))
|
.finally(() => setLoading(false));
|
||||||
.catch(() => setHealth("unreachable"));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
||||||
|
if (!user) return <Login onLoggedIn={setUser} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
||||||
<h1>Parking System</h1>
|
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||||
<p>
|
<h1 style={{ margin: 0 }}>Parking System</h1>
|
||||||
API health: <strong>{health}</strong>
|
<span style={{ color: "#555" }}>
|
||||||
</p>
|
{user.username} ({user.role}){" "}
|
||||||
<SetupWizard />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
setUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{user.role === "admin" ? (
|
||||||
|
<SetupWizard />
|
||||||
|
) : (
|
||||||
|
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,11 +11,9 @@ import {
|
|||||||
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
// 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
|
// 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
|
// 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.
|
// 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 }[] = [
|
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
|
||||||
{ key: "access", title: "Access controller" },
|
{ key: "access", title: "Access controller" },
|
||||||
@@ -28,7 +26,6 @@ export function SetupWizard() {
|
|||||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||||
const [lane, setLane] = useState(1);
|
const [lane, setLane] = useState(1);
|
||||||
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,22 +49,6 @@ export function SetupWizard() {
|
|||||||
style={{ width: "4rem" }}
|
style={{ width: "4rem" }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{catalog.authBypass ? (
|
|
||||||
<span style={{ flex: 1, color: "#92400e" }}>
|
|
||||||
⚠️ auth bypass on (testing) — no token needed
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<label style={{ flex: 1 }}>
|
|
||||||
Admin token{" "}
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => setToken(e.target.value)}
|
|
||||||
placeholder="needed to scan / assign"
|
|
||||||
style={{ width: "60%" }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{CATEGORIES.map(({ key, title }) => (
|
{CATEGORIES.map(({ key, title }) => (
|
||||||
@@ -76,8 +57,6 @@ export function SetupWizard() {
|
|||||||
title={title}
|
title={title}
|
||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
discoverableIds={catalog.discoverable}
|
discoverableIds={catalog.discoverable}
|
||||||
token={token}
|
|
||||||
authBypass={catalog.authBypass}
|
|
||||||
selectedId={picked[key]}
|
selectedId={picked[key]}
|
||||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||||||
/>
|
/>
|
||||||
@@ -90,16 +69,12 @@ function CategoryPicker({
|
|||||||
title,
|
title,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
token,
|
|
||||||
authBypass,
|
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
token: string;
|
|
||||||
authBypass: boolean;
|
|
||||||
selectedId: string | undefined;
|
selectedId: string | undefined;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -117,7 +92,7 @@ function CategoryPicker({
|
|||||||
setScanning(true);
|
setScanning(true);
|
||||||
setScanError(null);
|
setScanError(null);
|
||||||
try {
|
try {
|
||||||
setFound(await discoverDevices(token, selected.id));
|
setFound(await discoverDevices(selected.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setScanError((e as Error).message);
|
setScanError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,12 +128,9 @@ function CategoryPicker({
|
|||||||
|
|
||||||
{canDiscover && (
|
{canDiscover && (
|
||||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||||
<button type="button" onClick={scan} disabled={scanning || (!authBypass && !token)}>
|
<button type="button" onClick={scan} disabled={scanning}>
|
||||||
{scanning ? "Scanning…" : "Scan for controllers"}
|
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||||
</button>
|
</button>
|
||||||
{!authBypass && !token && (
|
|
||||||
<span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>
|
|
||||||
)}
|
|
||||||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||||
{found && found.length > 0 && (
|
{found && found.length > 0 && (
|
||||||
|
|||||||
+82
-29
@@ -1,4 +1,78 @@
|
|||||||
// Thin API client for the operator/admin UI.
|
// 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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SessionUser> {
|
||||||
|
return apiFetch<SessionUser>("/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<SessionUser | null> {
|
||||||
|
try {
|
||||||
|
return await apiFetch<SessionUser>("/api/auth/me");
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Device setup ---------------------------------------------------------
|
||||||
|
|
||||||
export interface ConfigField {
|
export interface ConfigField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -22,14 +96,10 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|||||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||||
/** Driver ids that support LAN discovery. */
|
/** Driver ids that support LAN discovery. */
|
||||||
discoverable: string[];
|
discoverable: string[];
|
||||||
/** True when setup endpoints skip admin auth (testing only) — no token needed. */
|
|
||||||
authBypass: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchCatalog(): Promise<Catalog> {
|
export function fetchCatalog(): Promise<Catalog> {
|
||||||
const res = await fetch("/api/setup/catalog");
|
return apiFetch<Catalog>("/api/setup/catalog");
|
||||||
if (!res.ok) throw new Error(`catalog: ${res.status}`);
|
|
||||||
return res.json() as Promise<Catalog>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveredDevice {
|
export interface DiscoveredDevice {
|
||||||
@@ -41,18 +111,10 @@ export interface DiscoveredDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */
|
/** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */
|
||||||
export async function discoverDevices(
|
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
||||||
token: string,
|
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
||||||
driverId: string,
|
`/api/setup/discover/${driverId}`,
|
||||||
): Promise<DiscoveredDevice[]> {
|
);
|
||||||
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[] };
|
|
||||||
return body.devices;
|
return body.devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,15 +125,6 @@ export interface AssignBody {
|
|||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assignDevice(token: string, body: AssignBody): Promise<unknown> {
|
export function assignDevice(body: AssignBody): Promise<unknown> {
|
||||||
const res = await fetch("/api/setup/assign", {
|
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11
@@ -20,6 +20,9 @@ importers:
|
|||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fastify/cookie':
|
||||||
|
specifier: ^11.0.2
|
||||||
|
version: 11.0.2
|
||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: 11.2.0
|
specifier: 11.2.0
|
||||||
version: 11.2.0
|
version: 11.2.0
|
||||||
@@ -605,6 +608,9 @@ packages:
|
|||||||
'@fastify/ajv-compiler@4.0.5':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
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':
|
'@fastify/cors@11.2.0':
|
||||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||||
|
|
||||||
@@ -1778,6 +1784,11 @@ snapshots:
|
|||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 3.1.2
|
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':
|
'@fastify/cors@11.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fastify-plugin: 5.1.0
|
fastify-plugin: 5.1.0
|
||||||
|
|||||||
@@ -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
|
- `@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
|
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).
|
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 /
|
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
|
||||||
readonly**. No Casbin or full RBAC engine needed at this scale.
|
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
|
> **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
|
> verifying hosts hold only a public key — [[open-questions]] #7. Relevant before any
|
||||||
> multi-host/multi-lane deployment.
|
> multi-host/multi-lane deployment.
|
||||||
|
|||||||
+11
@@ -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.
|
programmable aux input + PULL SDK but that's unverified and needs a new driver.
|
||||||
Recorded in [[access-controller-button-flow]] + [[zkteco-controller]]. Entry-lane
|
Recorded in [[access-controller-button-flow]] + [[zkteco-controller]]. Entry-lane
|
||||||
hardware decision paused to focus on the business side.
|
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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user