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>
59 lines
2.4 KiB
TypeScript
59 lines
2.4 KiB
TypeScript
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<string> {
|
|
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<AdminAuthResult> {
|
|
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;
|
|
}
|
|
}
|