7afcb2cb5a
- remove `protectAdmin`/`isAdmin` re-exports from `features/admin/index.js` to avoid top-level `next/headers` import - add `./features/admin/protect` export entry in `package.json` - lazy-import `next/headers` in `router.js` `requireAuth` to defer resolution - update `features/admin/README.md` to document new import paths - translate `features/auth/index.js` comment to French for consistency
301 lines
9.9 KiB
JavaScript
301 lines
9.9 KiB
JavaScript
/**
|
|
* API Router
|
|
*
|
|
* Orchestre rate limiting, CSRF, enforcement d'auth et dispatch vers les handlers.
|
|
* N'a aucune connaissance des features spécifiques — les routes s'auto-enregistrent.
|
|
*
|
|
* Initialisation requise :
|
|
* Appeler configureRouter({ resolveSession }) une fois au démarrage (dans initializeZen)
|
|
* avant toute requête. Le resolver est fourni par la feature auth, ce qui garde
|
|
* core/api/ libre de tout import feature.
|
|
*
|
|
* Cycle de vie d'une requête :
|
|
* route-handler.js → routeRequest()
|
|
* → rate limit (routes skipRateLimit exemptées)
|
|
* → validation CSRF (méthodes state-mutating uniquement)
|
|
* → matching sur toutes les routes (core en premier, puis features, puis modules)
|
|
* → enforcement auth depuis la définition de route
|
|
* → handler(request, params, context)
|
|
*/
|
|
|
|
import { getSessionCookieName } from '@zen/core/shared/config';
|
|
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
|
|
import { fail } from '@zen/core/shared/logger';
|
|
import { hasPermission, PERMISSIONS } from '@zen/core/users';
|
|
import { getCoreRoutes } from './core-routes.js';
|
|
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
|
|
import { apiError } from './respond.js';
|
|
|
|
export { configureRouter, getSessionResolver, clearRouterConfig } from './runtime.js';
|
|
|
|
const COOKIE_NAME = getSessionCookieName();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Exige une session valide. Lève une erreur si aucun cookie valide n'est présent.
|
|
* @returns {Promise<Object>} session
|
|
*/
|
|
export async function requireAuth() {
|
|
const { cookies } = await import('next/headers');
|
|
const cookieStore = await cookies();
|
|
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
|
|
|
if (!sessionToken) {
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
const session = await getSessionResolver()(sessionToken);
|
|
|
|
if (!session || !session.user) {
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Exige une session avec la permission admin.access.
|
|
* @returns {Promise<Object>} session
|
|
*/
|
|
export async function requireAdmin() {
|
|
const session = await requireAuth();
|
|
|
|
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
|
|
if (!allowed) {
|
|
throw new Error('Admin access required');
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSRF
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Résout l'URL canonique de l'application depuis les variables d'environnement.
|
|
* Priorité : NEXT_PUBLIC_URL_DEV (développement) → NEXT_PUBLIC_URL (production).
|
|
*/
|
|
function resolveAppUrl() {
|
|
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) {
|
|
return process.env.NEXT_PUBLIC_URL_DEV;
|
|
}
|
|
return process.env.NEXT_PUBLIC_URL || null;
|
|
}
|
|
|
|
/**
|
|
* Vérifie que les requêtes state-mutating proviennent de l'origine attendue.
|
|
* GET, HEAD et OPTIONS sont exemptés (RFC 7231).
|
|
* @param {Request} request
|
|
* @returns {boolean}
|
|
*/
|
|
function passesCsrfCheck(request) {
|
|
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
if (safeMethods.has(request.method)) return true;
|
|
|
|
const appUrl = resolveAppUrl();
|
|
if (!appUrl) {
|
|
fail('CSRF: no app URL configured (NEXT_PUBLIC_URL_DEV / NEXT_PUBLIC_URL) — request denied');
|
|
return false;
|
|
}
|
|
|
|
let expectedOrigin;
|
|
try {
|
|
expectedOrigin = new URL(appUrl).origin;
|
|
} catch {
|
|
fail(`CSRF: configured app URL is not valid: ${appUrl}`);
|
|
return false;
|
|
}
|
|
|
|
const origin = request.headers.get('origin');
|
|
if (origin) {
|
|
return origin === expectedOrigin;
|
|
}
|
|
|
|
// Pas d'en-tête Origin : repli sur Referer (anciens navigateurs).
|
|
const referer = request.headers.get('referer');
|
|
if (referer) {
|
|
try {
|
|
return new URL(referer).origin === expectedOrigin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Ni Origin ni Referer — refus par sécurité.
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route matching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Teste un pattern de route contre un chemin de requête.
|
|
*
|
|
* Supporte :
|
|
* - Segments exacts : '/health'
|
|
* - Paramètres nommés : '/users/:id'
|
|
* - Wildcard greedy (fin uniquement) : '/storage/**'
|
|
*
|
|
* @param {string} pattern
|
|
* @param {string} path
|
|
* @returns {boolean}
|
|
*/
|
|
function matchRoute(pattern, path) {
|
|
const patternParts = pattern.split('/').filter(Boolean);
|
|
const pathParts = path.split('/').filter(Boolean);
|
|
|
|
const hasWildcard = patternParts[patternParts.length - 1] === '**';
|
|
|
|
if (hasWildcard) {
|
|
const fixedParts = patternParts.slice(0, -1);
|
|
if (pathParts.length < fixedParts.length) return false;
|
|
for (let i = 0; i < fixedParts.length; i++) {
|
|
if (fixedParts[i].startsWith(':')) continue;
|
|
if (fixedParts[i] !== pathParts[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (patternParts.length !== pathParts.length) return false;
|
|
|
|
for (let i = 0; i < patternParts.length; i++) {
|
|
if (patternParts[i].startsWith(':')) continue;
|
|
if (patternParts[i] !== pathParts[i]) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Extrait les paramètres de chemin nommés (et le wildcard) d'une route matchée.
|
|
*
|
|
* @param {string} pattern - Ex. '/users/:id'
|
|
* @param {string} path - Ex. '/users/42'
|
|
* @returns {Object} params — paramètres nommés + `wildcard` optionnel
|
|
*/
|
|
function extractPathParams(pattern, path) {
|
|
const params = {};
|
|
const patternParts = pattern.split('/').filter(Boolean);
|
|
const pathParts = path.split('/').filter(Boolean);
|
|
|
|
for (let i = 0; i < patternParts.length; i++) {
|
|
const part = patternParts[i];
|
|
if (part === '**') {
|
|
params.wildcard = pathParts.slice(i).join('/');
|
|
break;
|
|
}
|
|
if (part.startsWith(':')) {
|
|
params[part.slice(1)] = pathParts[i];
|
|
}
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main router
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Messages sûrs à exposer verbatim au client.
|
|
const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
|
|
|
|
// Émis au plus une fois par lifetime de process pour éviter le log flooding.
|
|
let _rateLimitUnavailableWarned = false;
|
|
|
|
/**
|
|
* Route une requête API vers le handler approprié.
|
|
*
|
|
* @param {Request} request - Requête Next.js entrante
|
|
* @param {string[]} path - Segments de chemin après /zen/api/
|
|
* @returns {Promise<Object>} Payload de réponse (sérialisé en JSON par route-handler.js)
|
|
*/
|
|
export async function routeRequest(request, path) {
|
|
const method = request.method;
|
|
const pathString = '/' + path.join('/');
|
|
|
|
// Fusion de toutes les routes — core en premier pour que les built-ins aient priorité.
|
|
// Le rate limit est différé après le matching pour pouvoir honorer skipRateLimit
|
|
// sans hardcoder de chemins dans le router.
|
|
const allRoutes = [...getCoreRoutes(), ...getFeatureRoutes()];
|
|
|
|
const matchedRoute = allRoutes.find(
|
|
route => matchRoute(route.path, pathString) && route.method === method
|
|
);
|
|
|
|
// Rate limit par IP. Les routes avec skipRateLimit: true sont exemptées
|
|
// (ex. GET /health pour que les sondes de monitoring ne consomment pas de quota).
|
|
if (!matchedRoute?.skipRateLimit) {
|
|
const ip = getIpFromRequest(request);
|
|
if (ip === 'unknown') {
|
|
// L'IP client ne peut pas être résolue — appliquer le rate limit sur la clé
|
|
// 'unknown' partagée effondrerait tout le trafic dans un seul bucket, permettant
|
|
// à un seul attaquant d'épuiser le quota et de dénier le service à tous les autres.
|
|
// Le rate limiting est donc suspendu jusqu'à ce qu'un reverse proxy de confiance
|
|
// soit configuré avec ZEN_TRUST_PROXY=true.
|
|
if (!_rateLimitUnavailableWarned) {
|
|
_rateLimitUnavailableWarned = true;
|
|
fail(
|
|
'Rate limiting inactive: client IP cannot be determined. ' +
|
|
'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.'
|
|
);
|
|
}
|
|
} else {
|
|
const rl = checkRateLimit(ip, 'api');
|
|
if (!rl.allowed) {
|
|
return apiError(
|
|
'Too Many Requests',
|
|
`Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validation CSRF pour les requêtes state-mutating.
|
|
if (!passesCsrfCheck(request)) {
|
|
return apiError('Forbidden', 'CSRF validation failed');
|
|
}
|
|
|
|
if (!matchedRoute) {
|
|
// Aucune route matchée — message générique sans refléter la méthode ou le chemin
|
|
// pour éviter l'énumération de routes.
|
|
return apiError('Not Found', 'The requested resource does not exist');
|
|
}
|
|
|
|
// Enforcement auth depuis la définition de route, avant d'appeler le handler.
|
|
const context = {};
|
|
try {
|
|
if (matchedRoute.auth === 'admin') {
|
|
context.session = await requireAdmin();
|
|
if (matchedRoute.permission) {
|
|
const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
|
|
if (!allowed) {
|
|
return apiError('Forbidden', 'Permission insuffisante');
|
|
}
|
|
}
|
|
} else if (matchedRoute.auth === 'user') {
|
|
context.session = await requireAuth();
|
|
}
|
|
// 'public' — context.session reste undefined
|
|
} catch (err) {
|
|
const code = SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error';
|
|
if (!SAFE_AUTH_MESSAGES.has(err.message)) {
|
|
fail(`Auth error: ${err.message}`);
|
|
}
|
|
return apiError(code, code);
|
|
}
|
|
|
|
const params = extractPathParams(matchedRoute.path, pathString);
|
|
|
|
try {
|
|
return await matchedRoute.handler(request, params, context);
|
|
} catch (err) {
|
|
fail(`Route handler error [${method} ${matchedRoute.path}]: ${err.message}`);
|
|
return apiError('Internal Server Error', 'An unexpected error occurred. Please try again later.');
|
|
}
|
|
}
|