'use server'; import { register, login, requestPasswordReset, resetPassword, verifyUserEmail, completeAccountSetup } from './auth.js'; import { validateSession, deleteSession } from './session.js'; import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js'; import { fail } from '@zen/core/shared/logger'; import { cookies, headers } from 'next/headers'; import { getSessionCookieName, getPublicBaseUrl } from '@zen/core/shared/config'; import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '@zen/core/shared/rate-limit'; /** * 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'; } } async function getClientIp() { const h = await headers(); 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 ':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. */ 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 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. */ function validateAntiBotFields(formData) { const honeypot = formData.get('_hp'); if (honeypot && honeypot.length > 0) { return { valid: false, error: 'Requête invalide' }; } const MIN_ELAPSED_MS = 1_500; const MAX_FORM_AGE_MS = 10 * 60 * 1_000; 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' }; } return { valid: true }; } const COOKIE_NAME = getSessionCookieName(); export async function registerAction(formData) { try { const botCheck = validateAntiBotFields(formData); if (!botCheck.valid) return { success: false, error: botCheck.error }; const ip = await getClientIp(); const rl = enforceRateLimit(ip, 'register'); if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } const email = formData.get('email'); const password = formData.get('password'); const name = formData.get('name'); const result = await register({ email, password, name }); await sendVerificationEmail(result.user.email, result.verificationToken, getPublicBaseUrl()); return { success: true, message: 'Compte créé avec succès. Consultez votre e-mail pour vérifier votre compte.', user: result.user }; } catch (error) { // 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.' }; } } export async function loginAction(formData) { try { const botCheck = validateAntiBotFields(formData); if (!botCheck.valid) return { success: false, error: botCheck.error }; const h = await headers(); const ip = getIpFromHeaders(h); const rl = enforceRateLimit(ip, 'login'); if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } const email = formData.get('email'); const password = formData.get('password'); const userAgent = h.get('user-agent') || null; const result = await login({ email, password }, { ipAddress: ip !== 'unknown' ? ip : null, userAgent }); // An HttpOnly cookie is the only safe transport for session tokens; setting it // here keeps the token out of any JavaScript-readable response payload. const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, result.session.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, path: '/' }); return { success: true, message: 'Connexion réussie', user: result.user }; } catch (error) { fail(`Auth: loginAction error: ${error.message}`); return { success: false, error: 'Identifiants invalides ou erreur interne. Veuillez réessayer.' }; } } /** * Set session cookie after verifying the token is a genuine live session. * * 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, bypassing the protection * HttpOnly is intended to provide. The token is therefore validated against * the session store before the cookie is written. */ 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, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, path: '/' }); return { success: true }; } catch (error) { fail(`Auth: setSessionCookie error: ${error.message}`); return { success: false, error: 'Une erreur interne est survenue' }; } } /** * Re-validates the token before extending its cookie lifetime so that expired * or revoked tokens cannot have their cookie window reopened by replay. */ 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, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, path: '/' }); return { success: true }; } catch (error) { fail(`Auth: refreshSessionCookie error: ${error.message}`); return { success: false, error: 'Une erreur interne est survenue' }; } } export async function logoutAction() { try { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; if (token) { await deleteSession(token); } cookieStore.delete(COOKIE_NAME); return { success: true, message: 'Déconnexion réussie' }; } catch (error) { fail(`Auth: logoutAction error: ${error.message}`); return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } export async function getSession() { try { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; if (!token) return null; const result = await validateSession(token); if (result && result.sessionRefreshed) { await refreshSessionCookie(token); } return result; } catch (error) { fail(`Auth: session validation error: ${error.message}`); return null; } } export async function forgotPasswordAction(formData) { try { const botCheck = validateAntiBotFields(formData); if (!botCheck.valid) return { success: false, error: botCheck.error }; const ip = await getClientIp(); const rl = enforceRateLimit(ip, 'forgot_password'); if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } const email = formData.get('email'); const result = await requestPasswordReset(email); if (result.token) { await sendPasswordResetEmail(email, result.token, getPublicBaseUrl()); } return { success: true, message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.' }; } catch (error) { fail(`Auth: forgotPasswordAction error: ${error.message}`); return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } export async function resetPasswordAction(formData) { try { const ip = await getClientIp(); const rl = enforceRateLimit(ip, 'reset_password'); 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'); const newPassword = formData.get('newPassword'); // 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 UserFacingError('Jeton de réinitialisation invalide ou expiré'); } await resetPassword({ email, token, newPassword }); return { success: true, message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.' }; } catch (error) { 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.' }; } } export async function setupAccountAction(formData) { try { const ip = await getClientIp(); const rl = enforceRateLimit(ip, 'setup_account'); 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'); const newPassword = formData.get('newPassword'); const confirmPassword = formData.get('confirmPassword'); if (!newPassword || !confirmPassword) { throw new UserFacingError('Les deux champs de mot de passe sont requis'); } if (newPassword !== confirmPassword) { throw new UserFacingError('Les mots de passe ne correspondent pas'); } await completeAccountSetup({ email, token, password: newPassword }); return { success: true, message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.' }; } catch (error) { if (error instanceof UserFacingError) { return { success: false, error: error.message }; } fail(`Auth: setupAccountAction error: ${error.message}`); return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } export async function verifyEmailAction(formData) { try { const ip = await getClientIp(); 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'); const isValid = await verifyEmailToken(email, token); if (!isValid) { throw new UserFacingError('Jeton de vérification invalide ou expiré'); } const { findOne } = await import('../../core/database/crud.js'); const user = await findOne('zen_auth_users', { email }); if (!user) { throw new UserFacingError('Utilisateur introuvable'); } await verifyUserEmail(user.id); return { success: true, message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.' }; } catch (error) { 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.' }; } }