306 lines
7.9 KiB
TypeScript
306 lines
7.9 KiB
TypeScript
|
|
//! 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 {
|
||
|
|
ApiError,
|
||
|
|
AuthTokens,
|
||
|
|
LoginRequest,
|
||
|
|
RegisterRequest,
|
||
|
|
User,
|
||
|
|
} from '@/types';
|
||
|
|
import { csrfService } from './csrf';
|
||
|
|
|
||
|
|
// Schémas de validation
|
||
|
|
const UserSchema = z.object({
|
||
|
|
id: z.number(),
|
||
|
|
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(),
|
||
|
|
});
|
||
|
|
|
||
|
|
// const _ApiErrorSchema = z.object({
|
||
|
|
// message: z.string(),
|
||
|
|
// code: z.string().optional(),
|
||
|
|
// details: z.record(z.string(), z.unknown()).optional(),
|
||
|
|
// })
|
||
|
|
|
||
|
|
// Configuration
|
||
|
|
const API_BASE_URL =
|
||
|
|
import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||
|
|
|
||
|
|
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();
|
||
|
|
this.user = UserSchema.parse(data.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);
|
||
|
|
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<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();
|
||
|
|
this.user = UserSchema.parse(data.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',
|
||
|
|
'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();
|