//! Service d'authentification sécurisé avec cookies httpOnly //! //! Ce service gère: //! - Authentification via cookies httpOnly //! - Gestion des tokens CSRF //! - Protection contre les attaques XSS et CSRF import { z } from 'zod'; import type { AuthTokens, LoginRequest, RegisterRequest, User } from '@/types'; import { csrfService } from './csrf'; // Custom Error Class export class ApiError extends Error { code: string; details?: Record; constructor( message: string, code: string = 'UNKNOWN_ERROR', details?: Record, ) { super(message); this.name = 'ApiError'; this.code = code; this.details = details; } } // Schémas de validation const UserSchema = z.object({ id: z.string().uuid(), // Fixed: number -> string, now validates UUID format username: z.string(), email: z.string().email(), first_name: z.string().optional(), last_name: z.string().optional(), role: z.enum(['user', 'admin', 'super_admin']), is_active: z.boolean(), is_verified: z.boolean(), created_at: z.string(), last_login_at: z.string().optional(), avatar_url: z.string().optional(), bio: z.string().optional(), is_admin: z.boolean().default(false), is_public: z.boolean().default(false), updated_at: z.string().default(new Date().toISOString()), }); const AuthTokensSchema = z.object({ access_token: z.string(), refresh_token: z.string(), expires_in: z.number(), }); // Configuration const API_BASE_URL = (() => { const url = import.meta.env.VITE_API_URL; if (!url) { if (import.meta.env.PROD) { throw new Error('VITE_API_URL must be defined in production'); } // Fallback uniquement en développement return 'http://127.0.0.1:8080/api/v1'; } return url; })(); export class SecureAuthService { private static instance: SecureAuthService; private isAuthenticatedFlag: boolean = false; private user: User | null = null; private constructor() { this.checkAuthenticationStatus(); } public static getInstance(): SecureAuthService { if (!SecureAuthService.instance) { SecureAuthService.instance = new SecureAuthService(); } return SecureAuthService.instance; } /** * Vérifie le statut d'authentification via les cookies */ private async checkAuthenticationStatus(): Promise { try { const response = await fetch(`${API_BASE_URL}/auth/me`, { method: 'GET', credentials: 'include', // Important pour les cookies httpOnly headers: { 'Content-Type': 'application/json', ...csrfService.getCsrfHeaders(), }, }); if (response.ok) { const data = await response.json(); this.user = UserSchema.parse(data.user) as User; this.isAuthenticatedFlag = true; console.debug('Utilisateur authentifié via cookies:', this.user); } else { this.clearAuth(); } } catch (error) { console.warn( "Erreur lors de la vérification de l'authentification:", error, ); this.clearAuth(); } } /** * Connexion avec cookies httpOnly */ public async login( credentials: LoginRequest, ): Promise<{ user: User; tokens: AuthTokens }> { try { // Récupérer le token CSRF avant la connexion await csrfService.refreshCsrfToken(); const response = await fetch(`${API_BASE_URL}/auth/login`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfService.getCsrfHeaders(), }, body: JSON.stringify(credentials), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new ApiError( errorData.message || 'Erreur de connexion', errorData.code || 'LOGIN_ERROR', ); } const data = await response.json(); const user = UserSchema.parse(data.user) as User; const tokens = AuthTokensSchema.parse(data.tokens); // Les tokens sont automatiquement stockés dans les cookies httpOnly // On ne les stocke pas dans localStorage pour la sécurité this.user = user; this.isAuthenticatedFlag = true; console.debug('Connexion réussie avec cookies httpOnly:', user); return { user, tokens }; } catch (error) { console.error('Erreur de connexion:', error); this.clearAuth(); throw error; } } /** * Inscription avec cookies httpOnly */ public async register( userData: RegisterRequest, ): Promise<{ user: User; tokens: AuthTokens }> { try { // Récupérer le token CSRF avant l'inscription await csrfService.refreshCsrfToken(); const response = await fetch(`${API_BASE_URL}/auth/register`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfService.getCsrfHeaders(), }, body: JSON.stringify(userData), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new ApiError( errorData.message || "Erreur d'inscription", errorData.code || 'REGISTER_ERROR', ); } const data = await response.json(); const user = UserSchema.parse(data.user); const tokens = AuthTokensSchema.parse(data.tokens); this.user = user; this.isAuthenticatedFlag = true; console.debug('Inscription réussie avec cookies httpOnly:', user); return { user, tokens }; } catch (error) { console.error("Erreur d'inscription:", error); this.clearAuth(); throw error; } } /** * Déconnexion */ public async logout(): Promise { try { await fetch(`${API_BASE_URL}/auth/logout`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfService.getCsrfHeaders(), }, }); } catch (error) { console.warn('Erreur lors de la déconnexion:', error); } finally { this.clearAuth(); } } /** * Rafraîchit les informations utilisateur */ public async refreshUser(): Promise { try { const response = await fetch(`${API_BASE_URL}/auth/me`, { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfService.getCsrfHeaders(), }, }); if (response.ok) { const data = await response.json(); this.user = UserSchema.parse(data.user) as User; this.isAuthenticatedFlag = true; return this.user; } else { this.clearAuth(); return null; } } catch (error) { console.warn("Erreur lors du rafraîchissement de l'utilisateur:", error); this.clearAuth(); return null; } } /** * Rafraîchit le token d'accès */ public async refreshAccessToken(): Promise { try { const response = await fetch(`${API_BASE_URL}/auth/refresh`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfService.getCsrfHeaders(), }, }); if (!response.ok) { throw new ApiError( 'Impossible de rafraîchir le token', 'REFRESH_ERROR', ); } const data = await response.json(); const tokens = AuthTokensSchema.parse(data.tokens); // Le nouveau token est automatiquement stocké dans les cookies httpOnly return tokens.access_token; } catch (error) { console.error('Erreur lors du rafraîchissement du token:', error); this.clearAuth(); throw error; } } /** * Vérifie si l'utilisateur est authentifié */ public isAuthenticated(): boolean { return this.isAuthenticatedFlag && this.user !== null; } /** * Obtient l'utilisateur actuel */ public getCurrentUser(): User | null { return this.user; } /** * Nettoie l'état d'authentification */ private clearAuth(): void { this.user = null; this.isAuthenticatedFlag = false; csrfService.clearCsrfToken(); } /** * Obtient les headers d'authentification pour les requêtes */ public getAuthHeaders(): Record { return { 'Content-Type': 'application/json', ...csrfService.getCsrfHeaders(), }; } } // Instance singleton export const secureAuthService = SecureAuthService.getInstance();