abd9d651dc
- add `createAccountSetup`, `verifyAccountSetupToken`, `deleteAccountSetupToken` to verifications core - add `completeAccountSetup` function to auth core for password creation on invite - add `InvitationEmail` template for sending invite links - add `SetupAccountPage` client page for invited users to set their password - add `UserCreateModal` admin component to invite new users - wire invitation action and API endpoint in auth feature - update admin `UsersPage` to include user creation modal - update auth and admin README docs
399 lines
14 KiB
JavaScript
399 lines
14 KiB
JavaScript
'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 '<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.
|
|
*/
|
|
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.' };
|
|
}
|
|
}
|