From cf8ef26f6378f155d62ed2909987837c6ea0318e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 12:02:19 +0200 Subject: [PATCH] feat(admin): fondation auth de l'application d'administration Premiere brique de l'app d'admin independante : une surface /admin/* ajoutee a apps/api, avec une authentification totalement distincte de celle des utilisateurs. - Table AdminUser isolee (aucune relation vers UserProfile), migration 20260828120000_admin_user. - lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET propre (jamais interchangeable avec JWT_SECRET). - middlewares/require-admin.ts : cookie admin_session dedie, re-check tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture requireInternalWorker). res.locals.adminUser type via AdminLocals. - modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST /logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de signup expose. - lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash + tokenVersion, dates ISO). - scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou ADMIN_INITIAL_*). - CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN + ADMIN_CORS_ORIGIN. - Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts (AdminUserView). - Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN, ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml, ci.yml mis a jour. - reset-db.ts truncate admin_users. - Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu / mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion dans la reponse), /me derriere requireAdmin, logout, et un cookie `session` d'utilisateur normal ne donne pas acces a /admin/*. Co-Authored-By: Claude Sonnet 5 --- .env.example | 21 +++ .github/workflows/ci.yml | 4 + apps/api/.env.test.example | 7 + .../20260828120000_admin_user/migration.sql | 15 ++ apps/api/prisma/schema.prisma | 33 ++++ apps/api/src/app.ts | 9 +- apps/api/src/config/env.ts | 21 +++ apps/api/src/lib/admin-jwt.ts | 59 +++++++ apps/api/src/lib/safe-admin.ts | 20 +++ apps/api/src/middlewares/require-admin.ts | 71 +++++++++ .../src/modules/admin/admin-auth.routes.ts | 51 ++++++ .../src/modules/admin/admin-auth.service.ts | 59 +++++++ apps/api/src/modules/admin/admin.routes.ts | 13 ++ apps/api/src/scripts/create-admin.ts | 56 +++++++ apps/api/test-support/reset-db.ts | 3 +- apps/api/test/admin-auth.test.ts | 149 ++++++++++++++++++ docker-compose.yml | 9 ++ packages/express-tools/src/express-server.ts | 10 +- packages/shared/src/index.ts | 2 + packages/shared/src/schemas/admin.ts | 18 +++ packages/shared/src/types/admin.ts | 16 ++ 21 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 apps/api/prisma/migrations/20260828120000_admin_user/migration.sql create mode 100644 apps/api/src/lib/admin-jwt.ts create mode 100644 apps/api/src/lib/safe-admin.ts create mode 100644 apps/api/src/middlewares/require-admin.ts create mode 100644 apps/api/src/modules/admin/admin-auth.routes.ts create mode 100644 apps/api/src/modules/admin/admin-auth.service.ts create mode 100644 apps/api/src/modules/admin/admin.routes.ts create mode 100644 apps/api/src/scripts/create-admin.ts create mode 100644 apps/api/test/admin-auth.test.ts create mode 100644 packages/shared/src/schemas/admin.ts create mode 100644 packages/shared/src/types/admin.ts diff --git a/.env.example b/.env.example index accdc43..469a3e2 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,27 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # (docker-compose.yml), serving both the API and the built frontend. # APP_PORT=3000 +# --- Admin application (apps/admin-web + the /admin/* API surface) --------- +# All optional: an instance that doesn't run the admin app needs none of +# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so +# leaving it out simply disables every /admin/* route. +# +# Secret for the admin session JWT — MUST be different from JWT_SECRET so an +# end-user token can never be replayed against /admin/*. Generate your own +# the same way as JWT_SECRET above. +# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars +# Origin apps/admin-web is served from, added to the CORS allow-list. +# ADMIN_CORS_ORIGIN=http://localhost:5174 +# Host port for the Docker `admin-web` service (static nginx serving the +# built admin frontend). +# ADMIN_WEB_PORT=3001 +# Optional — read only by `src/scripts/create-admin.ts` when its --email / +# --password / --name flags are omitted (e.g. to bootstrap the first admin +# from inside the container). Never read by the running server. +# ADMIN_INITIAL_EMAIL=ops@example.com +# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars +# ADMIN_INITIAL_NAME=Ops + # Optional — only set this to false if THIS deployment is served over # plain HTTP (no TLS in front of it). Left unset, the session cookie # requires HTTPS (Secure attribute) as it should for a real deployment; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6781a7..22a7922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,10 @@ env: # exercise the success path (matching secret), not just the "unset" # rejection every environment that doesn't set this gets by default. INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+" + # Same reasoning — lets admin-auth.test.ts exercise the admin login + # success path + the requireAdmin-guarded routes, not just the "no admin + # secret configured -> 401" path. + ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+" # Shared between the `test` job's own uvicorn step (below) and apps/api's # IntentServiceClient — see the `test` job for why this can't be a # `services:` container like postgres above (GitHub Actions can only pull diff --git a/apps/api/.env.test.example b/apps/api/.env.test.example index 9c15d39..2bfd73f 100644 --- a/apps/api/.env.test.example +++ b/apps/api/.env.test.example @@ -27,3 +27,10 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # success path (a request with a matching secret); every other test runs # fine without it. Any value at least 32 chars works locally. # INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars + +# Optional — set to run admin-auth.test.ts's login success path and the +# requireAdmin-guarded routes (any value at least 32 chars). Left unset, +# those cases self-skip and only the "no secret configured -> 401" path +# runs. Same "optional in test, fail-closed at runtime" posture as +# INTERNAL_WORKER_SECRET above. +# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars diff --git a/apps/api/prisma/migrations/20260828120000_admin_user/migration.sql b/apps/api/prisma/migrations/20260828120000_admin_user/migration.sql new file mode 100644 index 0000000..d9932ed --- /dev/null +++ b/apps/api/prisma/migrations/20260828120000_admin_user/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "admin_users" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "token_version" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_login_at" TIMESTAMP(3), + + CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "admin_users_email_key" ON "admin_users"("email"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f2ca89f..d8e08da 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -897,3 +897,36 @@ model TechStepTrainingSuggestion { @@map("tech_step_training_suggestion") } + +// ----------------------------------------------------------------------------- +// Admin application +// A separate operations app (usage metrics, microservice monitoring, tech-step +// correction triage) — see specs/backend-architecture.md's "admin" section. +// ----------------------------------------------------------------------------- + +/// An operator of the admin application (`apps/admin-web` / the `/admin/*` +/// API surface). Deliberately its **own** table with **no relation** to +/// `UserProfile`: admin access is a completely separate concern from being +/// an end user of the recipe app — a person can be one, both or neither, +/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct +/// cookies, distinct JWT secrets) never overlap. No self-service signup — +/// the first row is created out-of-band by `src/scripts/create-admin.ts`, +/// and (for now) there's no in-app admin-management UI. +model AdminUser { + id Int @id @default(autoincrement()) + email String @unique + /// argon2 hash of the password — same hashing as `UserProfile.passwordHash` + /// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test). + passwordHash String @map("password_hash") + /// Display name shown in the admin UI's account menu. + name String + /// Bumped to invalidate previously-issued admin JWTs — same mechanism as + /// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`. + tokenVersion Int @default(0) @map("token_version") + createdAt DateTime @default(now()) @map("created_at") + /// Stamped on every successful login — a cheap "is this account still in + /// use" signal for the operator managing admins by hand. + lastLoginAt DateTime? @map("last_login_at") + + @@map("admin_users") +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1d62303..8f71a24 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { errorLogger } from "./middlewares/error-logger.js"; import { requestLogger } from "./middlewares/request-logger.js"; +import { adminRouter } from "./modules/admin/admin.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js"; import { houseRouter } from "./modules/house/house.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; @@ -32,13 +33,19 @@ export function createServer(): ExpressServer { // pipeline (its "finish" listener still fires for a request that never // makes it past CORS/body-parsing, not just ones that reach a route). server.addMiddleware(requestLogger); - server.setupCore({ corsOrigin: env.CORS_ORIGIN }); + // Two allowed origins: the main app (`CORS_ORIGIN`) and the separate + // admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming + // `Origin` against any entry of the list. + server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] }); server.addRoute("get", "/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok" }); }); server.mountRouter("/auth", authRouter); + // Admin application surface (`apps/admin-web`) — its own auth + // (`requireAdmin`, distinct cookie/secret), never the end-user session. + server.mountRouter("/admin", adminRouter); server.mountRouter("/house", houseRouter); // Not user-facing — `services/tech-step-llm-worker` only, guarded by // `requireInternalWorker` on every route within (see that router's own diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 9224ecc..578c9de 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -90,6 +90,27 @@ const envSchema = z.object({ * every technique detection request failing one at a time. */ INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"), + /** + * Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`) + * — entirely separate from `JWT_SECRET`, so an end-user session token can + * never be replayed against `/admin/*` and vice versa. `.optional()` + * (unlike `JWT_SECRET`): an instance that doesn't run the admin app at + * all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`) + * rejects every request outright when it's unset, so the surface fails + * closed, same posture as `INTERNAL_WORKER_SECRET`. + */ + ADMIN_JWT_SECRET: z + .string() + .min(32, "ADMIN_JWT_SECRET must be at least 32 characters") + .optional(), + /** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */ + ADMIN_COOKIE_NAME: z.string().default("admin_session"), + /** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */ + ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"), + /** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */ + ADMIN_INITIAL_EMAIL: z.string().optional(), + ADMIN_INITIAL_PASSWORD: z.string().optional(), + ADMIN_INITIAL_NAME: z.string().optional(), }); /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ diff --git a/apps/api/src/lib/admin-jwt.ts b/apps/api/src/lib/admin-jwt.ts new file mode 100644 index 0000000..359bb2c --- /dev/null +++ b/apps/api/src/lib/admin-jwt.ts @@ -0,0 +1,59 @@ +import jwt from "jsonwebtoken"; +import { env } from "../config/env.js"; + +/** + * Decoded contents of an **admin** session JWT, once verified. Deliberately + * a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin + * app authenticates against its own `AdminUser` table with its own secret + * (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session + * token are never interchangeable. + */ +export interface AdminTokenPayload { + /** `AdminUser.id` this token authenticates. */ + adminUserId: number; + /** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */ + tokenVersion: number; +} + +/** + * The admin JWT secret, or a thrown error if the instance never configured + * one. A misconfigured admin deployment surfaces as a loud 500 on login + * rather than a silently-unsigned token; a deployment that doesn't run the + * admin app at all never reaches here (nothing calls sign/verify), and + * `requireAdmin` independently fails closed on the same unset value. + */ +function adminSecret(): string { + if (env.ADMIN_JWT_SECRET === undefined) { + throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions"); + } + return env.ADMIN_JWT_SECRET; +} + +/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */ +export function signAdminToken(payload: AdminTokenPayload): string { + return jwt.sign( + { sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion }, + adminSecret(), + { expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] }, + ); +} + +/** + * Verifies an admin session JWT's signature/expiry and decodes it back + * into an {@link AdminTokenPayload}. + * + * @throws {Error} if the token is invalid/expired (from `jwt.verify`) or + * structurally malformed (missing/wrong-typed claims). + */ +export function verifyAdminToken(token: string): AdminTokenPayload { + const decoded = jwt.verify(token, adminSecret()); + const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN; + if ( + typeof decoded !== "object" || + Number.isNaN(adminUserId) || + typeof decoded.tokenVersion !== "number" + ) { + throw new Error("Malformed admin token payload"); + } + return { adminUserId, tokenVersion: decoded.tokenVersion }; +} diff --git a/apps/api/src/lib/safe-admin.ts b/apps/api/src/lib/safe-admin.ts new file mode 100644 index 0000000..d03fbaf --- /dev/null +++ b/apps/api/src/lib/safe-admin.ts @@ -0,0 +1,20 @@ +import type { AdminUserView } from "@batch-cooking/shared"; +import type { AdminUser } from "@prisma/client"; + +/** + * Shapes a Prisma `AdminUser` into the {@link AdminUserView} sent to the + * admin client — drops `passwordHash` **and** `tokenVersion` (an internal + * invalidation counter the client never needs, unlike `SafeUserProfile` + * which does expose it), and serializes the two dates to ISO strings. The + * one place this security-relevant stripping happens, same role as + * `toSafeProfile` (`lib/safe-profile.ts`). + */ +export function toSafeAdmin(admin: AdminUser): AdminUserView { + return { + id: admin.id, + email: admin.email, + name: admin.name, + createdAt: admin.createdAt.toISOString(), + lastLoginAt: admin.lastLoginAt === null ? null : admin.lastLoginAt.toISOString(), + }; +} diff --git a/apps/api/src/middlewares/require-admin.ts b/apps/api/src/middlewares/require-admin.ts new file mode 100644 index 0000000..daf0569 --- /dev/null +++ b/apps/api/src/middlewares/require-admin.ts @@ -0,0 +1,71 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { type AdminUserView, ErrorCode } from "@batch-cooking/shared"; +import type { NextFunction, Request, Response } from "express"; +import { env } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { verifyAdminToken } from "../lib/admin-jwt.js"; +import { toSafeAdmin } from "../lib/safe-admin.js"; + +/** + * Shape of `res.locals` once {@link requireAdmin} has run successfully. + * Type a handler's response as `Response` to read + * `res.locals.adminUser` fully typed, no cast — same `res.locals` (not + * global `Request` augmentation) approach as {@link AuthLocals} + * (`require-auth.ts`). + */ +export interface AdminLocals { + /** The authenticated admin operator, resolved from the admin session cookie's JWT. */ + adminUser: AdminUserView; +} + +/** + * Express middleware guarding every `/admin/*` route — the operations app + * (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin + * session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie + * as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET` + * (a different secret than `JWT_SECRET`), and re-checks `tokenVersion` + * against the database so a stateless JWT can still be invalidated + * server-side. + * + * A completely separate mechanism from {@link requireAuth}, not layered on + * it: an end-user session token and an admin session token are never + * interchangeable in either direction. + * + * Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance + * that doesn't run the admin app) makes {@link verifyAdminToken} throw, so + * every request is rejected rather than the surface left open — same + * posture as `requireInternalWorker`. + * + * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing + * cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or + * no secret configured. Never distinguishes the reason. + */ +export async function requireAdmin( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const token = req.cookies?.[env.ADMIN_COOKIE_NAME]; + if (typeof token !== "string") { + throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); + } + + const payload = verifyAdminToken(token); + const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } }); + + if (!admin || admin.tokenVersion !== payload.tokenVersion) { + throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); + } + + res.locals.adminUser = toSafeAdmin(admin); + next(); + } catch (err) { + if (err instanceof HttpError) { + next(err); + } else { + // Covers jwt.verify failures and the unset-secret throw from verifyAdminToken. + next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); + } + } +} diff --git a/apps/api/src/modules/admin/admin-auth.routes.ts b/apps/api/src/modules/admin/admin-auth.routes.ts new file mode 100644 index 0000000..ca12ede --- /dev/null +++ b/apps/api/src/modules/admin/admin-auth.routes.ts @@ -0,0 +1,51 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { adminLoginSchema } from "@batch-cooking/shared"; +import { type CookieOptions, type Response, Router } from "express"; +import { env } from "../../config/env.js"; +import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js"; +import { adminLogin } from "./admin-auth.service.js"; + +/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */ +export const adminAuthRouter = Router(); + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Cookie options for the admin session — same shape as `auth.routes.ts`'s + * end-user cookie (httpOnly, `Secure` in production unless `COOKIE_SECURE` + * overrides, `SameSite=Lax`), just written under {@link env.ADMIN_COOKIE_NAME} + * so the two sessions never collide in one browser. + */ +const adminCookieOptions: CookieOptions = { + httpOnly: true, + secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production", + sameSite: "lax", + maxAge: SEVEN_DAYS_MS, +}; + +// `res.clearCookie` sets its own expiry — passing `maxAge` alongside is +// deprecated as of Express 4.20, so the logout route reuses the options +// minus that one field (same trick as `auth.routes.ts`). +const { maxAge: _maxAge, ...clearAdminCookieOptions } = adminCookieOptions; + +/** Verifies admin credentials and starts an admin session. */ +adminAuthRouter.post( + "/login", + wrapAsyncHandler(async (req, res) => { + const input = adminLoginSchema.parse(req.body); + const { admin, token } = await adminLogin(input); + res.cookie(env.ADMIN_COOKIE_NAME, token, adminCookieOptions); + res.status(200).json(admin); + }), +); + +/** Ends the admin session by clearing the cookie. Stateless JWT — nothing to revoke server-side beyond bumping `tokenVersion` (no UI for that yet). */ +adminAuthRouter.post("/logout", (_req, res) => { + res.clearCookie(env.ADMIN_COOKIE_NAME, clearAdminCookieOptions); + res.status(204).end(); +}); + +/** Returns the currently authenticated admin. Behind `requireAdmin` — 401s if there's no valid admin session. */ +adminAuthRouter.get("/me", requireAdmin, (_req, res: Response) => { + res.status(200).json(res.locals.adminUser); +}); diff --git a/apps/api/src/modules/admin/admin-auth.service.ts b/apps/api/src/modules/admin/admin-auth.service.ts new file mode 100644 index 0000000..7833809 --- /dev/null +++ b/apps/api/src/modules/admin/admin-auth.service.ts @@ -0,0 +1,59 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { type AdminLoginInput, type AdminUserView, ErrorCode } from "@batch-cooking/shared"; +import argon2 from "argon2"; +import { env } from "../../config/env.js"; +import { prisma } from "../../db/prisma.js"; +import { signAdminToken } from "../../lib/admin-jwt.js"; +import { toSafeAdmin } from "../../lib/safe-admin.js"; + +/** Result of a successful admin login: the safe admin view plus the signed admin session JWT to set as a cookie. */ +interface AdminAuthResult { + admin: AdminUserView; + token: string; +} + +// Same reasoning as `auth.service.ts`'s `hashOptions`: argon2's real +// defaults are deliberately expensive; the test suite hashes/verifies +// against throwaway data many times per run, so a cheaper cost keeps it +// fast without weakening anything real. Never applies outside NODE_ENV=test. +const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; +const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; + +/** Exposed so `src/scripts/create-admin.ts` hashes exactly the same way `login` verifies. */ +export function hashAdminPassword(password: string): Promise { + return argon2.hash(password, hashOptions); +} + +/** + * Verifies an admin operator's credentials, stamps `lastLoginAt`, and + * issues a fresh admin session token. + * + * @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email + * or a wrong password — deliberately indistinguishable, same reasoning as + * `auth.service.ts`'s `login`. + */ +export async function adminLogin(input: AdminLoginInput): Promise { + try { + const admin = await prisma.adminUser.findUnique({ where: { email: input.email } }); + + if (!admin || !(await argon2.verify(admin.passwordHash, input.password))) { + throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password"); + } + + const updated = await prisma.adminUser.update({ + where: { id: admin.id }, + data: { lastLoginAt: new Date() }, + }); + + const token = signAdminToken({ + adminUserId: updated.id, + tokenVersion: updated.tokenVersion, + }); + return { admin: toSafeAdmin(updated), token }; + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it, + // this service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..ce51e05 --- /dev/null +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { adminAuthRouter } from "./admin-auth.routes.js"; + +/** + * Aggregator for the admin application's API surface, mounted at `/admin` + * in `app.ts`. Every sub-router here is for `apps/admin-web` only — + * `/admin/auth` is public (login), everything added later + * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits + * behind `requireAdmin` (`middlewares/require-admin.ts`). + */ +export const adminRouter = Router(); + +adminRouter.use("/auth", adminAuthRouter); diff --git a/apps/api/src/scripts/create-admin.ts b/apps/api/src/scripts/create-admin.ts new file mode 100644 index 0000000..d7bf977 --- /dev/null +++ b/apps/api/src/scripts/create-admin.ts @@ -0,0 +1,56 @@ +import { env } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { hashAdminPassword } from "../modules/admin/admin-auth.service.js"; + +/** + * Creates the first (or an additional) `AdminUser` for the admin + * application — there is no self-service admin signup, on purpose (see the + * `AdminUser` model doc comment in schema.prisma). + * + * pnpm --filter api exec tsx src/scripts/create-admin.ts \ + * --email=ops@example.com --password='...' --name='Ops' + * + * Each flag falls back to the matching `ADMIN_INITIAL_*` env var when + * omitted, so a deployment can bake the first admin's credentials into its + * environment and run this once from the container without passing args. + * Refuses (exit 1) if an `AdminUser` with that email already exists — + * changing an existing admin's password is a manual DB operation for now, + * not something this script does. + */ +function flag(name: string): string | undefined { + const prefix = `--${name}=`; + const arg = process.argv.find((value) => value.startsWith(prefix)); + return arg === undefined ? undefined : arg.slice(prefix.length); +} + +async function createAdmin(): Promise { + const email = (flag("email") ?? env.ADMIN_INITIAL_EMAIL)?.trim().toLowerCase(); + const password = flag("password") ?? env.ADMIN_INITIAL_PASSWORD; + const name = (flag("name") ?? env.ADMIN_INITIAL_NAME)?.trim(); + + if (!email || !password || !name) { + throw new Error( + "Missing required input. Provide --email, --password and --name (or set ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_PASSWORD / ADMIN_INITIAL_NAME).", + ); + } + if (password.length < 8) { + throw new Error("Password must be at least 8 characters."); + } + + const existing = await prisma.adminUser.findUnique({ where: { email } }); + if (existing) { + throw new Error(`An admin with email "${email}" already exists (id ${existing.id}).`); + } + + const passwordHash = await hashAdminPassword(password); + const admin = await prisma.adminUser.create({ data: { email, name, passwordHash } }); + console.info(`Created admin #${admin.id} <${admin.email}> ("${admin.name}").`); +} + +createAdmin() + .then(() => prisma.$disconnect()) + .catch(async (err) => { + console.error(err instanceof Error ? err.message : err); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index c12b1ac..6d98bd6 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -47,7 +47,8 @@ export async function resetDatabase() { "planning_item", "planning", "recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe", "ingredients", "sources", "unit", - "user_profiles", "diet", "house" + "user_profiles", "diet", "house", + "admin_users" RESTART IDENTITY CASCADE; `); await seedReferenceData(prisma); diff --git a/apps/api/test/admin-auth.test.ts b/apps/api/test/admin-auth.test.ts new file mode 100644 index 0000000..4922318 --- /dev/null +++ b/apps/api/test/admin-auth.test.ts @@ -0,0 +1,149 @@ +import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { env } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */ +async function seedAdmin(): Promise<{ email: string; password: string }> { + const email = faker.internet.email().toLowerCase(); + const password = faker.internet.password({ length: 16 }); + await prisma.adminUser.create({ + data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, + }); + return { email, password }; +} + +/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */ +const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; + +describe("Admin auth", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("POST /admin/auth/login", () => { + it("rejects a missing body with 400 VALIDATION_ERROR", async () => { + const res = await request(app).post("/admin/auth/login").send({}); + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => { + const res = await request(app) + .post("/admin/auth/login") + .send({ email: "nobody@example.com", password: "whatever" }); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); + }); + + it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { + const { email } = await seedAdmin(); + const res = await request(app) + .post("/admin/auth/login") + .send({ email, password: "not-the-password" }); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); + }); + + it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here). + (this as any).skip(); + return; + } + const { email, password } = await seedAdmin(); + + const res = await request(app).post("/admin/auth/login").send({ email, password }); + + expect(res.status).to.equal(200); + expect(res.body.email).to.equal(email); + expect(res.body).to.not.have.property("passwordHash"); + expect(res.body).to.not.have.property("tokenVersion"); + expect(res.body.lastLoginAt).to.be.a("string"); + + const setCookie = res.headers["set-cookie"]; + expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include( + env.ADMIN_COOKIE_NAME, + ); + + const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } }); + expect(stored.lastLoginAt).to.not.equal(null); + }); + }); + + describe("GET /admin/auth/me", () => { + it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/admin/auth/me"); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("returns the admin behind a valid admin session", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/auth/login").send({ email, password }); + + const res = await agent.get("/admin/auth/me"); + expect(res.status).to.equal(200); + expect(res.body.email).to.equal(email); + }); + + it("stops returning the admin after logout", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/auth/login").send({ email, password }); + + const logoutRes = await agent.post("/admin/auth/logout"); + expect(logoutRes.status).to.equal(204); + + const meRes = await agent.get("/admin/auth/me"); + expect(meRes.status).to.equal(401); + }); + + it("does not accept an end-user session cookie as an admin session", async () => { + // An ordinary user logs in (sets the `session` cookie), then tries the + // admin surface with that same agent — `requireAdmin` reads a + // different cookie entirely, so this must 401 regardless of whether + // ADMIN_JWT_SECRET is configured. + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/admin/auth/me"); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index d0fab73..e62058a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,15 @@ services: # default: `/internal/tech-steps/*` fails closed rather than open # for a deployment that doesn't run the worker at all. INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} + # Admin application (apps/admin-web + /admin/*). Both unset by default: + # `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack + # that doesn't run the admin app simply has every /admin/* route 401. + # Must be a *different* secret than JWT_SECRET. + ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-} + # Public origin apps/admin-web is served from, added to the CORS + # allow-list alongside the main app. Defaults to the compose + # `admin-web` service's mapped host port. + ADMIN_CORS_ORIGIN: ${ADMIN_CORS_ORIGIN:-http://localhost:3001} # Compose network service name, not localhost — same reasoning as # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # tech-step-intent-service is a core dependency (see its own entry diff --git a/packages/express-tools/src/express-server.ts b/packages/express-tools/src/express-server.ts index ceb1473..ce46149 100644 --- a/packages/express-tools/src/express-server.ts +++ b/packages/express-tools/src/express-server.ts @@ -13,8 +13,14 @@ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete"; /** Options for {@link ExpressServer.setupCore}. */ export interface ExpressServerCoreOptions { - /** Origin allowed by CORS — must match wherever the frontend is served from. */ - corsOrigin: string; + /** + * Origin(s) allowed by CORS — a single origin, or a list when more than + * one frontend talks to this API from a different origin (e.g. the main + * app plus a separate admin app). Passed straight through to the `cors` + * package, which matches an incoming `Origin` against any entry of the + * list. + */ + corsOrigin: string | string[]; } /** diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 73fb904..870ca99 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js"; export * from "./data/catalog-labels-fr.js"; export * from "./errors/error-codes.js"; export * from "./schemas/account.js"; +export * from "./schemas/admin.js"; export * from "./schemas/auth.js"; export * from "./schemas/household.js"; export * from "./schemas/planning.js"; @@ -17,6 +18,7 @@ export * from "./schemas/shopping-list.js"; export * from "./schemas/sources.js"; export * from "./schemas/tech-step-worker.js"; export * from "./tools/assert-is-never.js"; +export * from "./types/admin.js"; export * from "./types/household.js"; export * from "./types/planning.js"; export * from "./types/preferences.js"; diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts new file mode 100644 index 0000000..f02cf08 --- /dev/null +++ b/packages/shared/src/schemas/admin.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +// Shared between apps/api (server-side validation, source of truth) and +// apps/admin-web (client-side validation for instant feedback). Same +// rationale as schemas/auth.ts — one set of rules, French messages surfaced +// as-is in the admin login form. + +/** + * Payload accepted by `POST /admin/auth/login`. Deliberately its own schema + * (not a re-export of `loginSchema`): the admin surface is a separate + * contract from the end-user one even though the shape currently matches. + */ +export const adminLoginSchema = z.object({ + email: z.string().trim().toLowerCase().email("Email invalide"), + password: z.string().min(1, "Le mot de passe est requis"), +}); +/** Inferred TS type for {@link adminLoginSchema}'s validated output. */ +export type AdminLoginInput = z.infer; diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts new file mode 100644 index 0000000..499e5eb --- /dev/null +++ b/packages/shared/src/types/admin.ts @@ -0,0 +1,16 @@ +/** + * Public shape of an admin operator, as returned by `GET /admin/auth/me` + * and `POST /admin/auth/login` — never includes the password hash. Mirrors + * apps/api's `Omit`, + * declared by hand rather than derived from the Prisma type (same reason as + * {@link SafeUserProfile}: apps/admin-web must not depend on + * `@prisma/client`). `createdAt`/`lastLoginAt` are ISO 8601 strings (JSON + * has no date type). + */ +export interface AdminUserView { + id: number; + email: string; + name: string; + createdAt: string; + lastLoginAt: string | null; +}