veza/apps/web/src/services/secure-auth.ts

320 lines
8.3 KiB
TypeScript
Raw Normal View History

//! 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';
2025-12-13 02:34:34 +00:00
import type { AuthTokens, LoginRequest, RegisterRequest, User } from '@/types';
import { csrfService } from './csrf';
2025-12-13 02:34:34 +00:00
// Custom Error Class
export class ApiError extends Error {
code: string;
details?: Record<string, unknown>;
constructor(
message: string,
code: string = 'UNKNOWN_ERROR',
details?: Record<string, unknown>,
) {
super(message);
this.name = 'ApiError';
this.code = code;
this.details = details;
}
}
// Schémas de validation
const UserSchema = z.object({
2025-12-13 02:34:34 +00:00
id: z.string(), // Fixed: number -> string
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(),
});
const AuthTokensSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number(),
});
// Configuration
2025-12-17 13:07:35 +00:00
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<void> {
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();
2025-12-13 02:34:34 +00:00
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:",
2025-12-13 02:34:34 +00:00
error,
);
this.clearAuth();
}
}
/**
* Connexion avec cookies httpOnly
*/
public async login(
2025-12-13 02:34:34 +00:00
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',
2025-12-13 02:34:34 +00:00
errorData.code || 'LOGIN_ERROR',
);
}
const data = await response.json();
2025-12-13 02:34:34 +00:00
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(
2025-12-13 02:34:34 +00:00
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",
2025-12-13 02:34:34 +00:00
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<void> {
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<User | null> {
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();
2025-12-13 02:34:34 +00:00
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<string> {
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',
2025-12-13 02:34:34 +00:00
'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<string, string> {
return {
'Content-Type': 'application/json',
...csrfService.getCsrfHeaders(),
};
}
}
// Instance singleton
export const secureAuthService = SecureAuthService.getInstance();