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 <noreply@anthropic.com>
71 lines
2.8 KiB
TypeScript
71 lines
2.8 KiB
TypeScript
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<unknown, AdminLocals>` 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<unknown, AdminLocals>,
|
|
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"));
|
|
}
|
|
}
|
|
}
|