refactor(api): update README and refactor api route registration

Restructure the core API to separate infrastructure routes from feature
routes. Key changes:

- Add `runtime.js` for global state: session resolver and feature route
  registry
- Add `file-response.js` for streaming file responses (storage endpoint)
- Remove feature routes (auth/users) from `core-routes.js`, keeping only
  true infrastructure routes (health, storage)
- Introduce `registerFeatureRoutes()` so features self-register during
  `initializeZen()` instead of being hardcoded in `core-routes.js`
- Add `UserFacingError` class to safely surface client-facing errors
  without leaking internal details
- Fix import path for `rateLimit.js` to use shared lib location
- Update README to reflect new two-step registration flow and clarify
  the role of `core-routes.js`
This commit is contained in:
2026-04-13 17:20:14 -04:00
parent a3921a0b98
commit 59fce3cd91
14 changed files with 515 additions and 380 deletions
+135 -54
View File
@@ -11,7 +11,20 @@ import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPassword
import { fail } from '../../../shared/lib/logger.js';
import { cookies, headers } from 'next/headers';
import { getSessionCookieName, getPublicBaseUrl } from '../../../shared/lib/appConfig.js';
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../lib/rateLimit.js';
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../../../shared/lib/rateLimit.js';
/**
* Errors that are safe to surface verbatim to the client (e.g. "token expired").
* All other errors — including library-layer and database errors — must be caught,
* logged server-side only, and replaced with a generic message to prevent internal
* detail disclosure.
*/
class UserFacingError extends Error {
constructor(message) {
super(message);
this.name = 'UserFacingError';
}
}
/**
* Get the client IP from the current server action context.
@@ -21,10 +34,49 @@ async function getClientIp() {
return getIpFromHeaders(h);
}
// Emitted at most once per process lifetime to avoid log flooding while still
// alerting operators that per-IP rate limiting is inactive for server actions.
let _rateLimitUnavailableWarned = false;
/**
* Apply per-IP rate limiting only when a real IP address is available.
*
* When IP resolves to 'unknown' (no trusted proxy configured), every caller
* shares the single bucket keyed '<action>:unknown'. A single attacker can
* exhaust that bucket in 5 requests and impose a 30-minute denial-of-service
* on every legitimate user. Rate limiting is therefore suspended for the
* 'unknown' case and a one-time operator warning is emitted instead,
* mirroring the identical policy applied to API routes in router.js.
*
* Set ZEN_TRUST_PROXY=true only behind a verified reverse proxy (Nginx,
* Cloudflare, AWS ALB, …) that strips and rewrites forwarding headers.
*
* @param {string} ip
* @param {string} action
* @returns {{ allowed: boolean, retryAfterMs?: number } | null} null = suspended
*/
function enforceRateLimit(ip, action) {
if (ip === 'unknown') {
if (!_rateLimitUnavailableWarned) {
_rateLimitUnavailableWarned = true;
fail(
'Rate limiting inactive (server actions): client IP cannot be determined. ' +
'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.'
);
}
return null;
}
return checkRateLimit(ip, action);
}
/**
* Validate anti-bot fields submitted with forms.
* - _hp : honeypot field — must be empty
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page load
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page
* load AND no more than MAX_FORM_AGE_MS in the past. Both a lower bound
* (prevents instant automated submission) and an upper bound (prevents the
* trivial bypass of supplying an arbitrary past timestamp such as _t=1) are
* enforced. Future timestamps are also rejected.
*
* @param {FormData} formData
* @returns {{ valid: boolean, error?: string }}
@@ -35,8 +87,13 @@ function validateAntiBotFields(formData) {
return { valid: false, error: 'Requête invalide' };
}
const t = parseInt(formData.get('_t') || '0', 10);
if (t === 0 || Date.now() - t < 1500) {
const MIN_ELAPSED_MS = 1_500;
const MAX_FORM_AGE_MS = 10 * 60 * 1_000; // 10 minutes — rejects epoch-era timestamps
const now = Date.now();
const t = parseInt(formData.get('_t') || '0', 10);
const elapsed = now - t;
if (t === 0 || t > now || elapsed < MIN_ELAPSED_MS || elapsed > MAX_FORM_AGE_MS) {
return { valid: false, error: 'Requête invalide' };
}
@@ -57,8 +114,8 @@ export async function registerAction(formData) {
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'register');
if (!rl.allowed) {
const rl = enforceRateLimit(ip, 'register');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
@@ -77,10 +134,10 @@ export async function registerAction(formData) {
user: result.user
};
} catch (error) {
return {
success: false,
error: error.message
};
// Never return raw error.message to the client — library and database errors
// (e.g. unique-constraint violations) expose internal table names and schema.
fail(`Auth: registerAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
@@ -95,8 +152,8 @@ export async function loginAction(formData) {
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'login');
if (!rl.allowed) {
const rl = enforceRateLimit(ip, 'login');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
@@ -125,20 +182,34 @@ export async function loginAction(formData) {
// JavaScript-accessible response body.
};
} catch (error) {
return {
success: false,
error: error.message
};
fail(`Auth: loginAction error: ${error.message}`);
return { success: false, error: 'Identifiants invalides ou erreur interne. Veuillez réessayer.' };
}
}
/**
* Set session cookie (called by client after showing success message)
* @param {string} token - Session token
* Set session cookie after verifying the token is a genuine live session.
*
* This server action is client-callable. Without server-side token validation an
* attacker could supply any arbitrary string (including a stolen token for another
* user) and have it written as the HttpOnly session cookie, completely bypassing
* the protection that HttpOnly is intended to provide. The token is therefore
* validated against the session store before the cookie is written.
*
* @param {string} token - Session token obtained from a trusted server-side flow
* @returns {Promise<Object>} Result object
*/
export async function setSessionCookie(token) {
try {
if (!token || typeof token !== 'string' || token.trim() === '') {
return { success: false, error: 'Jeton de session invalide' };
}
const session = await validateSession(token);
if (!session) {
return { success: false, error: 'Session invalide ou expirée' };
}
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
@@ -150,20 +221,32 @@ export async function setSessionCookie(token) {
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
fail(`Auth: setSessionCookie error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue' };
}
}
/**
* Refresh session cookie (extend expiration)
* Refresh session cookie (extend expiration).
*
* Re-validates the token before extending its cookie lifetime so that expired
* or revoked tokens cannot have their cookie window reopened by replaying this
* server action.
*
* @param {string} token - Session token
* @returns {Promise<Object>} Result object
*/
export async function refreshSessionCookie(token) {
try {
if (!token || typeof token !== 'string' || token.trim() === '') {
return { success: false, error: 'Jeton de session invalide' };
}
const session = await validateSession(token);
if (!session) {
return { success: false, error: 'Session invalide ou expirée' };
}
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
@@ -175,10 +258,8 @@ export async function refreshSessionCookie(token) {
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
fail(`Auth: refreshSessionCookie error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue' };
}
}
@@ -202,10 +283,8 @@ export async function logoutAction() {
message: 'Déconnexion réussie'
};
} catch (error) {
return {
success: false,
error: error.message
};
fail(`Auth: logoutAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
@@ -245,8 +324,8 @@ export async function forgotPasswordAction(formData) {
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'forgot_password');
if (!rl.allowed) {
const rl = enforceRateLimit(ip, 'forgot_password');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
@@ -263,10 +342,8 @@ export async function forgotPasswordAction(formData) {
message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.'
};
} catch (error) {
return {
success: false,
error: error.message
};
fail(`Auth: forgotPasswordAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
@@ -278,8 +355,8 @@ export async function forgotPasswordAction(formData) {
export async function resetPasswordAction(formData) {
try {
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'reset_password');
if (!rl.allowed) {
const rl = enforceRateLimit(ip, 'reset_password');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
@@ -287,10 +364,11 @@ export async function resetPasswordAction(formData) {
const token = formData.get('token');
const newPassword = formData.get('newPassword');
// Verify token first
// Verify token first — throw UserFacingError so the specific message reaches
// the client while unexpected system errors are sanitized in the catch below.
const isValid = await verifyResetToken(email, token);
if (!isValid) {
throw new Error('Jeton de réinitialisation invalide ou expiré');
throw new UserFacingError('Jeton de réinitialisation invalide ou expiré');
}
await resetPassword({ email, token, newPassword });
@@ -300,10 +378,11 @@ export async function resetPasswordAction(formData) {
message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.'
};
} catch (error) {
return {
success: false,
error: error.message
};
if (error instanceof UserFacingError) {
return { success: false, error: error.message };
}
fail(`Auth: resetPasswordAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
@@ -315,18 +394,19 @@ export async function resetPasswordAction(formData) {
export async function verifyEmailAction(formData) {
try {
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'verify_email');
if (!rl.allowed) {
const rl = enforceRateLimit(ip, 'verify_email');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
// Verify token
// Verify token — throw UserFacingError so validation messages surface to the
// client while unexpected system errors remain sanitized in the catch below.
const isValid = await verifyEmailToken(email, token);
if (!isValid) {
throw new Error('Jeton de vérification invalide ou expiré');
throw new UserFacingError('Jeton de vérification invalide ou expiré');
}
// Find user and verify
@@ -334,7 +414,7 @@ export async function verifyEmailAction(formData) {
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Utilisateur introuvable');
throw new UserFacingError('Utilisateur introuvable');
}
await verifyUserEmail(user.id);
@@ -344,10 +424,11 @@ export async function verifyEmailAction(formData) {
message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
};
} catch (error) {
return {
success: false,
error: error.message
};
if (error instanceof UserFacingError) {
return { success: false, error: error.message };
}
fail(`Auth: verifyEmailAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
-158
View File
@@ -1,158 +0,0 @@
/**
* In-memory rate limiter
* Stores counters in a Map — resets on server restart, no DB required.
*/
// Persist the store on globalThis so that module-cache invalidation (e.g. during
// Next.js hot reload) does not silently reset all counters within the same process.
// CRITICAL LIMITATION: this Map is process-local. In serverless or multi-worker
// deployments every instance maintains its own store and rate limits do not
// distribute across instances. For production deployments with multiple workers
// replace this Map with a shared atomic store (e.g. Redis / Upstash).
const STORE_KEY = Symbol.for('__ZEN_RATE_LIMIT_STORE__');
if (!globalThis[STORE_KEY]) globalThis[STORE_KEY] = new Map();
/** @type {Map<string, { count: number, windowStart: number, windowMs: number, blockedUntil: number|null }>} */
const store = globalThis[STORE_KEY];
// Purge expired entries every 10 minutes to avoid memory leak
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of store.entries()) {
const windowExpired = now > entry.windowStart + entry.windowMs;
const blockExpired = !entry.blockedUntil || now > entry.blockedUntil;
if (windowExpired && blockExpired) {
store.delete(key);
}
}
}, 10 * 60 * 1000);
// Allow garbage collection in test/serverless environments
if (cleanup.unref) cleanup.unref();
/**
* Rate limit presets per action.
* maxAttempts : number of requests allowed in the window
* windowMs : rolling window duration
* blockMs : how long to block once the limit is exceeded
*/
export const RATE_LIMITS = {
login: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
register: { maxAttempts: 5, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
forgot_password: { maxAttempts: 3, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
reset_password: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
verify_email: { maxAttempts: 10, windowMs: 60 * 60 * 1000, blockMs: 30 * 60 * 1000 },
api: { maxAttempts: 120, windowMs: 60 * 1000, blockMs: 5 * 60 * 1000 },
};
/**
* Check whether a given identifier is allowed for an action, and record the attempt.
*
* @param {string} identifier - IP address or user ID
* @param {string} action - Key from RATE_LIMITS (e.g. 'login')
* @returns {{ allowed: boolean, retryAfterMs?: number, remaining?: number }}
*/
export function checkRateLimit(identifier, action) {
const config = RATE_LIMITS[action];
if (!config) return { allowed: true };
const key = `${action}:${identifier}`;
const now = Date.now();
let entry = store.get(key);
// Still blocked
if (entry?.blockedUntil && now < entry.blockedUntil) {
return { allowed: false, retryAfterMs: entry.blockedUntil - now };
}
// Start a fresh window (first request, or previous window has expired)
if (!entry || now > entry.windowStart + entry.windowMs) {
store.set(key, { count: 1, windowStart: now, windowMs: config.windowMs, blockedUntil: null });
return { allowed: true, remaining: config.maxAttempts - 1 };
}
// Increment counter in the current window
entry.count += 1;
if (entry.count > config.maxAttempts) {
entry.blockedUntil = now + config.blockMs;
store.set(key, entry);
return { allowed: false, retryAfterMs: config.blockMs };
}
store.set(key, entry);
return { allowed: true, remaining: config.maxAttempts - entry.count };
}
/**
* Return true only when the string resembles a valid IPv4 or IPv6 address.
* This prevents arbitrary attacker-supplied strings from being used as
* rate-limit identifiers (which could allow bucket manipulation).
* @param {string|null|undefined} ip
* @returns {boolean}
*/
function isValidIp(ip) {
if (!ip || typeof ip !== 'string') return false;
// IPv4 — four decimal octets, each 0-255
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
return ip.split('.').every(octet => parseInt(octet, 10) <= 255);
}
// IPv6 — simplified structural check (colons + hex groups)
return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(':');
}
/**
* Extract the client IP from Next.js headers() (server actions).
*
* X-Forwarded-For and X-Real-IP are only trusted when ZEN_TRUST_PROXY=true is
* explicitly set, confirming a trusted reverse proxy populates those headers.
* Without this flag the headers are fully attacker-controlled and MUST NOT be
* used as rate-limit keys — an attacker would trivially rotate identifiers.
*
* Set ZEN_TRUST_PROXY=true only when a verified reverse proxy (e.g. Nginx,
* Cloudflare, AWS ALB) strips and rewrites forwarded headers before they reach
* this application.
*
* @param {import('next/headers').ReadonlyHeaders} headersList
* @returns {string}
*/
export function getIpFromHeaders(headersList) {
if (process.env.ZEN_TRUST_PROXY === 'true') {
const forwarded = headersList.get('x-forwarded-for')?.split(',')[0]?.trim();
if (forwarded && isValidIp(forwarded)) return forwarded;
const realIp = headersList.get('x-real-ip')?.trim();
if (realIp && isValidIp(realIp)) return realIp;
}
// Safe fallback — all requests share the 'unknown' bucket.
// Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy.
return 'unknown';
}
/**
* Extract the client IP from a Next.js Request object (API routes).
* See getIpFromHeaders for the full trust-proxy rationale.
* @param {Request} request
* @returns {string}
*/
export function getIpFromRequest(request) {
if (process.env.ZEN_TRUST_PROXY === 'true') {
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
if (forwarded && isValidIp(forwarded)) return forwarded;
const realIp = request.headers.get('x-real-ip')?.trim();
if (realIp && isValidIp(realIp)) return realIp;
}
return 'unknown';
}
/**
* Format a block duration in human-readable French.
* @param {number} ms
* @returns {string}
*/
export function formatRetryAfter(ms) {
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds} secondes`;
const minutes = Math.ceil(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
const hours = Math.ceil(minutes / 60);
return `${hours} heure${hours > 1 ? 's' : ''}`;
}