Authentification stable - WIP

This commit is contained in:
DahoudG
2025-09-19 12:35:46 +00:00
parent 63fe107f98
commit 098894bdc1
383 changed files with 13072 additions and 93334 deletions

View File

@@ -1,306 +0,0 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../core/network/dio_client.dart';
import '../models/login_request.dart';
import '../models/login_response.dart';
/// Service API pour l'authentification
@singleton
class AuthApiService {
final DioClient _dioClient;
late final Dio _dio;
AuthApiService(this._dioClient) {
_dio = _dioClient.dio;
}
/// Effectue la connexion utilisateur
Future<LoginResponse> login(LoginRequest request) async {
try {
final response = await _dio.post(
'/api/auth/login',
data: request.toJson(),
options: Options(
headers: {
'Content-Type': 'application/json',
},
// Désactiver l'interceptor d'auth pour cette requête
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return LoginResponse.fromJson(response.data);
} else {
throw AuthApiException(
'Erreur de connexion',
statusCode: response.statusCode,
response: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue lors de la connexion: $e');
}
}
/// Rafraîchit le token d'accès
Future<LoginResponse> refreshToken(String refreshToken) async {
try {
final response = await _dio.post(
'/api/auth/refresh',
data: {'refreshToken': refreshToken},
options: Options(
headers: {
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return LoginResponse.fromJson(response.data);
} else {
throw AuthApiException(
'Erreur lors du rafraîchissement du token',
statusCode: response.statusCode,
response: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue lors du rafraîchissement: $e');
}
}
/// Effectue la déconnexion
Future<void> logout(String? refreshToken) async {
try {
await _dio.post(
'/api/auth/logout',
data: refreshToken != null ? {'refreshToken': refreshToken} : {},
options: Options(
headers: {
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
} on DioException catch (e) {
// Ignorer les erreurs de déconnexion côté serveur
// La déconnexion locale est plus importante
print('Erreur lors de la déconnexion serveur: ${e.message}');
} catch (e) {
print('Erreur inattendue lors de la déconnexion: $e');
}
}
/// Valide un token côté serveur
Future<bool> validateToken(String accessToken) async {
try {
final response = await _dio.get(
'/api/auth/validate',
options: Options(
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
return response.statusCode == 200;
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
return false;
}
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur lors de la validation du token: $e');
}
}
/// Récupère les informations de l'API d'authentification
Future<Map<String, dynamic>> getAuthInfo() async {
try {
final response = await _dio.get(
'/api/auth/info',
options: Options(
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
} else {
throw AuthApiException(
'Erreur lors de la récupération des informations',
statusCode: response.statusCode,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue: $e');
}
}
/// Gestion centralisée des erreurs Dio
AuthApiException _handleDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return AuthApiException(
'Délai d\'attente dépassé. Vérifiez votre connexion internet.',
type: AuthErrorType.timeout,
);
case DioExceptionType.connectionError:
return AuthApiException(
'Impossible de se connecter au serveur. Vérifiez votre connexion internet.',
type: AuthErrorType.network,
);
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
final data = e.response?.data;
switch (statusCode) {
case 400:
return AuthApiException(
_extractErrorMessage(data) ?? 'Données de requête invalides',
statusCode: statusCode,
type: AuthErrorType.validation,
response: data,
);
case 401:
return AuthApiException(
_extractErrorMessage(data) ?? 'Identifiants invalides',
statusCode: statusCode,
type: AuthErrorType.unauthorized,
response: data,
);
case 403:
return AuthApiException(
_extractErrorMessage(data) ?? 'Accès interdit',
statusCode: statusCode,
type: AuthErrorType.forbidden,
response: data,
);
case 429:
return AuthApiException(
'Trop de tentatives. Veuillez réessayer plus tard.',
statusCode: statusCode,
type: AuthErrorType.rateLimited,
response: data,
);
case 500:
case 502:
case 503:
case 504:
return AuthApiException(
'Erreur serveur temporaire. Veuillez réessayer.',
statusCode: statusCode,
type: AuthErrorType.server,
response: data,
);
default:
return AuthApiException(
_extractErrorMessage(data) ?? 'Erreur serveur inconnue',
statusCode: statusCode,
response: data,
);
}
case DioExceptionType.cancel:
return AuthApiException(
'Requête annulée',
type: AuthErrorType.cancelled,
);
default:
return AuthApiException(
'Erreur réseau: ${e.message}',
type: AuthErrorType.unknown,
);
}
}
/// Extrait le message d'erreur de la réponse
String? _extractErrorMessage(dynamic data) {
if (data == null) return null;
if (data is Map<String, dynamic>) {
return data['message'] ?? data['error'] ?? data['detail'];
}
if (data is String) {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
return json['message'] ?? json['error'] ?? json['detail'];
} catch (_) {
return data;
}
}
return null;
}
}
/// Types d'erreurs d'authentification
enum AuthErrorType {
validation,
unauthorized,
forbidden,
timeout,
network,
server,
rateLimited,
cancelled,
unknown,
}
/// Exception spécifique à l'API d'authentification
class AuthApiException implements Exception {
final String message;
final int? statusCode;
final AuthErrorType type;
final dynamic response;
const AuthApiException(
this.message, {
this.statusCode,
this.type = AuthErrorType.unknown,
this.response,
});
bool get isNetworkError =>
type == AuthErrorType.network ||
type == AuthErrorType.timeout;
bool get isServerError => type == AuthErrorType.server;
bool get isClientError =>
type == AuthErrorType.validation ||
type == AuthErrorType.unauthorized ||
type == AuthErrorType.forbidden;
bool get isRetryable =>
isNetworkError ||
isServerError ||
type == AuthErrorType.rateLimited;
@override
String toString() {
return 'AuthApiException: $message ${statusCode != null ? '(Status: $statusCode)' : ''}';
}
}

View File

@@ -1,318 +0,0 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
import '../storage/secure_token_storage.dart';
import 'auth_api_service.dart';
import '../../network/auth_interceptor.dart';
import '../../network/dio_client.dart';
/// Service principal d'authentification
@singleton
class AuthService {
final SecureTokenStorage _tokenStorage;
final AuthApiService _apiService;
final AuthInterceptor _authInterceptor;
final DioClient _dioClient;
// Stream controllers pour notifier les changements d'état
final _authStateController = StreamController<AuthState>.broadcast();
final _tokenRefreshController = StreamController<void>.broadcast();
// Timers pour la gestion automatique des tokens
Timer? _tokenRefreshTimer;
Timer? _sessionExpiryTimer;
// État actuel
AuthState _currentState = const AuthState.unknown();
AuthService(
this._tokenStorage,
this._apiService,
this._authInterceptor,
this._dioClient,
) {
_initializeAuthInterceptor();
}
// Getters
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
/// Initialise l'interceptor d'authentification
void _initializeAuthInterceptor() {
_authInterceptor.setCallbacks(
onTokenRefreshNeeded: () => _refreshTokenSilently(),
onAuthenticationFailed: () => logout(),
);
_dioClient.addAuthInterceptor(_authInterceptor);
}
/// Initialise le service d'authentification
Future<void> initialize() async {
_updateState(const AuthState.checking());
try {
// Vérifier si des tokens existent
final hasTokens = await _tokenStorage.hasAuthData();
if (!hasTokens) {
_updateState(const AuthState.unauthenticated());
return;
}
// Récupérer les données d'authentification
final authData = await _tokenStorage.getAuthData();
if (authData == null) {
_updateState(const AuthState.unauthenticated());
return;
}
// Vérifier si les tokens sont expirés
if (authData.isRefreshTokenExpired) {
await _tokenStorage.clearAuthData();
_updateState(const AuthState.unauthenticated());
return;
}
// Si le token d'accès est expiré, essayer de le rafraîchir
if (authData.isAccessTokenExpired) {
await _refreshToken();
return;
}
// Valider le token côté serveur
final isValid = await _validateTokenWithServer(authData.accessToken);
if (!isValid) {
await _refreshToken();
return;
}
// Tout est OK, restaurer la session
_updateState(AuthState.authenticated(
user: authData.user,
accessToken: authData.accessToken,
refreshToken: authData.refreshToken,
expiresAt: authData.expiresAt,
));
_scheduleTokenRefresh();
_scheduleSessionExpiry();
} catch (e) {
print('Erreur lors de l\'initialisation de l\'auth: $e');
await _tokenStorage.clearAuthData();
_updateState(AuthState.error('Erreur d\'initialisation: $e'));
}
}
/// Connecte un utilisateur
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Appel API de connexion
final response = await _apiService.login(request);
// Sauvegarder les tokens
await _tokenStorage.saveAuthData(response);
// Mettre à jour l'état
_updateState(AuthState.authenticated(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresAt: response.expiresAt,
));
// Programmer les rafraîchissements
_scheduleTokenRefresh();
_scheduleSessionExpiry();
} on AuthApiException catch (e) {
_updateState(AuthState.error(e.message));
rethrow;
} catch (e) {
final errorMessage = 'Erreur de connexion: $e';
_updateState(AuthState.error(errorMessage));
rethrow;
}
}
/// Déconnecte l'utilisateur
Future<void> logout() async {
try {
// Récupérer le refresh token pour l'invalider côté serveur
final refreshToken = await _tokenStorage.getRefreshToken();
// Appel API de déconnexion (optionnel)
await _apiService.logout(refreshToken);
} catch (e) {
print('Erreur lors de la déconnexion serveur: $e');
}
// Nettoyage local (toujours fait)
await _tokenStorage.clearAuthData();
_cancelTimers();
_updateState(const AuthState.unauthenticated());
}
/// Rafraîchit le token d'accès
Future<void> _refreshToken() async {
try {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null) {
throw Exception('Aucun refresh token disponible');
}
// Vérifier si le refresh token est expiré
final refreshExpiresAt = await _tokenStorage.getRefreshTokenExpirationDate();
if (refreshExpiresAt != null && DateTime.now().isAfter(refreshExpiresAt)) {
throw Exception('Refresh token expiré');
}
// Appel API de refresh
final response = await _apiService.refreshToken(refreshToken);
// Mettre à jour le stockage
await _tokenStorage.updateAccessToken(response.accessToken, response.expiresAt);
// Mettre à jour l'état
if (_currentState.isAuthenticated) {
_updateState(_currentState.copyWith(
accessToken: response.accessToken,
expiresAt: response.expiresAt,
));
} else {
_updateState(AuthState.authenticated(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresAt: response.expiresAt,
));
}
// Reprogrammer les timers
_scheduleTokenRefresh();
} catch (e) {
print('Erreur lors du rafraîchissement du token: $e');
await logout();
}
}
/// Rafraîchit le token silencieusement (sans changer l'état de loading)
Future<void> _refreshTokenSilently() async {
try {
await _refreshToken();
_tokenRefreshController.add(null);
} catch (e) {
print('Erreur lors du rafraîchissement silencieux: $e');
}
}
/// Valide un token côté serveur
Future<bool> _validateTokenWithServer(String accessToken) async {
try {
return await _apiService.validateToken(accessToken);
} catch (e) {
print('Erreur lors de la validation du token: $e');
return false;
}
}
/// Programme le rafraîchissement automatique du token
void _scheduleTokenRefresh() {
_tokenRefreshTimer?.cancel();
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
return;
}
// Rafraîchir 5 minutes avant l'expiration
final refreshTime = _currentState.expiresAt!.subtract(const Duration(minutes: 5));
final delay = refreshTime.difference(DateTime.now());
if (delay.isNegative) {
// Le token expire bientôt, rafraîchir immédiatement
_refreshTokenSilently();
return;
}
_tokenRefreshTimer = Timer(delay, () => _refreshTokenSilently());
}
/// Programme l'expiration de la session
void _scheduleSessionExpiry() {
_sessionExpiryTimer?.cancel();
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
return;
}
final delay = _currentState.expiresAt!.difference(DateTime.now());
if (delay.isNegative) {
logout();
return;
}
_sessionExpiryTimer = Timer(delay, () {
_updateState(const AuthState.expired());
});
}
/// Annule tous les timers
void _cancelTimers() {
_tokenRefreshTimer?.cancel();
_sessionExpiryTimer?.cancel();
_tokenRefreshTimer = null;
_sessionExpiryTimer = null;
}
/// Met à jour l'état et notifie les listeners
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
/// Nettoie les ressources
void dispose() {
_cancelTimers();
_authStateController.close();
_tokenRefreshController.close();
}
/// Vérifie les permissions de l'utilisateur
bool hasRole(String role) {
return _currentState.user?.role == role;
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
final userRole = _currentState.user?.role;
return userRole != null && roles.contains(userRole);
}
/// Décode un token JWT (utilitaire)
Map<String, dynamic>? decodeToken(String token) {
try {
return JwtDecoder.decode(token);
} catch (e) {
print('Erreur lors du décodage du token: $e');
return null;
}
}
/// Vérifie si un token est expiré
bool isTokenExpired(String token) {
try {
return JwtDecoder.isExpired(token);
} catch (e) {
return true;
}
}
}

View File

@@ -0,0 +1,418 @@
/// Service d'Authentification Keycloak
/// Gère l'authentification avec votre instance Keycloak sur port 8180
library keycloak_auth_service;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
import 'keycloak_webview_auth_service.dart';
/// Configuration Keycloak pour votre instance
class KeycloakConfig {
/// URL de base de votre Keycloak
static const String baseUrl = 'http://192.168.1.145:8180';
/// Realm UnionFlow
static const String realm = 'unionflow';
/// Client ID pour l'application mobile
static const String clientId = 'unionflow-mobile';
/// URL de redirection après authentification
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
/// Scopes demandés
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
/// Endpoints calculés
static String get authorizationEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
static String get tokenEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get userInfoEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
static String get logoutEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
}
/// Service d'authentification Keycloak ultra-sophistiqué
class KeycloakAuthService {
static const FlutterAppAuth _appAuth = FlutterAppAuth();
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
// Clés de stockage sécurisé
static const String _accessTokenKey = 'keycloak_access_token';
static const String _refreshTokenKey = 'keycloak_refresh_token';
static const String _idTokenKey = 'keycloak_id_token';
static const String _userInfoKey = 'keycloak_user_info';
/// Authentification avec Keycloak via WebView (solution HTTP compatible)
///
/// Cette méthode utilise maintenant KeycloakWebViewAuthService pour contourner
/// les limitations HTTPS de flutter_appauth
static Future<AuthorizationTokenResponse?> authenticate() async {
try {
debugPrint('🔐 Démarrage authentification Keycloak via WebView...');
// Utiliser le service WebView pour l'authentification
// Cette méthode retourne null car l'authentification WebView
// est gérée différemment (via callback)
debugPrint('💡 Authentification WebView - utilisez authenticateWithWebView() à la place');
return null;
} catch (e, stackTrace) {
debugPrint('💥 Erreur authentification Keycloak: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Rafraîchit le token d'accès
static Future<TokenResponse?> refreshToken() async {
try {
final String? refreshToken = await _secureStorage.read(
key: _refreshTokenKey,
);
if (refreshToken == null) {
debugPrint('❌ Aucun refresh token disponible');
return null;
}
debugPrint('🔄 Rafraîchissement du token...');
final TokenRequest request = TokenRequest(
KeycloakConfig.clientId,
KeycloakConfig.redirectUrl,
refreshToken: refreshToken,
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
tokenEndpoint: KeycloakConfig.tokenEndpoint,
),
);
final TokenResponse? result = await _appAuth.token(request);
if (result != null) {
await _storeTokens(result);
debugPrint('✅ Token rafraîchi avec succès');
return result;
}
debugPrint('❌ Échec du rafraîchissement du token');
return null;
} catch (e, stackTrace) {
debugPrint('💥 Erreur rafraîchissement token: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Récupère l'utilisateur authentifié depuis les tokens
static Future<User?> getCurrentUser() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
final String? idToken = await _secureStorage.read(
key: _idTokenKey,
);
if (accessToken == null || idToken == null) {
debugPrint('❌ Tokens manquants');
return null;
}
// Vérifier si les tokens sont expirés
if (JwtDecoder.isExpired(accessToken)) {
debugPrint('⏰ Access token expiré, tentative de rafraîchissement...');
final TokenResponse? refreshResult = await refreshToken();
if (refreshResult == null) {
debugPrint('❌ Impossible de rafraîchir le token');
return null;
}
}
// Décoder les tokens JWT
final Map<String, dynamic> accessTokenPayload =
JwtDecoder.decode(accessToken);
final Map<String, dynamic> idTokenPayload =
JwtDecoder.decode(idToken);
debugPrint('🔍 Payload Access Token: $accessTokenPayload');
debugPrint('🔍 Payload ID Token: $idTokenPayload');
// Extraire les informations utilisateur
final String userId = idTokenPayload['sub'] ?? '';
final String email = idTokenPayload['email'] ?? '';
final String firstName = idTokenPayload['given_name'] ?? '';
final String lastName = idTokenPayload['family_name'] ?? '';
final String fullName = idTokenPayload['name'] ?? '$firstName $lastName';
// Extraire les rôles Keycloak
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
debugPrint('🎭 Rôles Keycloak extraits: $keycloakRoles');
// Si aucun rôle, assigner un rôle par défaut
if (keycloakRoles.isEmpty) {
debugPrint('⚠️ Aucun rôle trouvé, assignation du rôle MEMBER par défaut');
keycloakRoles.add('member');
}
// Mapper vers notre système de rôles
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
debugPrint('🎯 Rôle principal mappé: ${primaryRole.displayName}');
debugPrint('🔐 Permissions mappées: ${permissions.length} permissions');
debugPrint('📋 Permissions détaillées: $permissions');
// Créer l'utilisateur
final User user = User(
id: userId,
email: email,
firstName: firstName,
lastName: lastName,
primaryRole: primaryRole,
organizationContexts: [], // À implémenter selon vos besoins
additionalPermissions: permissions,
revokedPermissions: [],
preferences: const UserPreferences(),
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible
isActive: true,
);
// Stocker les informations utilisateur
await _secureStorage.write(
key: _userInfoKey,
value: jsonEncode(user.toJson()),
);
debugPrint('✅ Utilisateur récupéré: ${user.fullName} (${user.primaryRole.displayName})');
return user;
} catch (e, stackTrace) {
debugPrint('💥 Erreur récupération utilisateur: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Déconnexion complète
static Future<bool> logout() async {
try {
debugPrint('🚪 Déconnexion Keycloak...');
final String? idToken = await _secureStorage.read(key: _idTokenKey);
// Déconnexion côté Keycloak si possible
if (idToken != null) {
try {
final EndSessionRequest request = EndSessionRequest(
idTokenHint: idToken,
postLogoutRedirectUrl: KeycloakConfig.redirectUrl,
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
tokenEndpoint: KeycloakConfig.tokenEndpoint,
endSessionEndpoint: KeycloakConfig.logoutEndpoint,
),
);
await _appAuth.endSession(request);
debugPrint('✅ Déconnexion Keycloak côté serveur réussie');
} catch (e) {
debugPrint('⚠️ Déconnexion côté serveur échouée: $e');
// Continue quand même avec la déconnexion locale
}
}
// Nettoyage local des tokens
await _clearTokens();
debugPrint('✅ Déconnexion locale terminée');
return true;
} catch (e, stackTrace) {
debugPrint('💥 Erreur déconnexion: $e');
debugPrint('Stack trace: $stackTrace');
return false;
}
}
/// Vérifie si l'utilisateur est authentifié
static Future<bool> isAuthenticated() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
if (accessToken == null) {
return false;
}
// Vérifier si le token est expiré
if (JwtDecoder.isExpired(accessToken)) {
// Tenter de rafraîchir
final TokenResponse? refreshResult = await refreshToken();
return refreshResult != null;
}
return true;
} catch (e) {
debugPrint('💥 Erreur vérification authentification: $e');
return false;
}
}
/// Stocke les tokens de manière sécurisée
static Future<void> _storeTokens(TokenResponse tokenResponse) async {
if (tokenResponse.accessToken != null) {
await _secureStorage.write(
key: _accessTokenKey,
value: tokenResponse.accessToken!,
);
}
if (tokenResponse.refreshToken != null) {
await _secureStorage.write(
key: _refreshTokenKey,
value: tokenResponse.refreshToken!,
);
}
if (tokenResponse.idToken != null) {
await _secureStorage.write(
key: _idTokenKey,
value: tokenResponse.idToken!,
);
}
debugPrint('🔒 Tokens stockés de manière sécurisée');
}
/// Nettoie tous les tokens stockés
static Future<void> _clearTokens() async {
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
await _secureStorage.delete(key: _idTokenKey);
await _secureStorage.delete(key: _userInfoKey);
debugPrint('🧹 Tokens nettoyés');
}
/// Extrait les rôles depuis le payload JWT Keycloak
static List<String> _extractKeycloakRoles(Map<String, dynamic> payload) {
final List<String> roles = [];
try {
// Rôles du realm
final Map<String, dynamic>? realmAccess = payload['realm_access'];
if (realmAccess != null && realmAccess['roles'] is List) {
final List<dynamic> realmRoles = realmAccess['roles'];
roles.addAll(realmRoles.cast<String>());
}
// Rôles des clients
final Map<String, dynamic>? resourceAccess = payload['resource_access'];
if (resourceAccess != null) {
resourceAccess.forEach((clientId, clientData) {
if (clientData is Map<String, dynamic> && clientData['roles'] is List) {
final List<dynamic> clientRoles = clientData['roles'];
roles.addAll(clientRoles.cast<String>());
}
});
}
// Filtrer les rôles système Keycloak
return roles.where((role) =>
!role.startsWith('default-roles-') &&
role != 'offline_access' &&
role != 'uma_authorization'
).toList();
} catch (e) {
debugPrint('💥 Erreur extraction rôles: $e');
return [];
}
}
/// Récupère le token d'accès actuel
static Future<String?> getAccessToken() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
return accessToken;
}
// Token expiré, tenter de rafraîchir
final TokenResponse? refreshResult = await refreshToken();
return refreshResult?.accessToken;
} catch (e) {
debugPrint('💥 Erreur récupération access token: $e');
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// MÉTHODES WEBVIEW - Délégation vers KeycloakWebViewAuthService
// ═══════════════════════════════════════════════════════════════════════════
/// Prépare l'authentification WebView
///
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
static Future<Map<String, String>> prepareWebViewAuthentication() async {
return KeycloakWebViewAuthService.prepareAuthentication();
}
/// Traite le callback WebView et finalise l'authentification
///
/// Cette méthode doit être appelée quand l'URL de callback est interceptée
static Future<User> handleWebViewCallback(String callbackUrl) async {
return KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
}
/// Vérifie si l'utilisateur est authentifié (compatible WebView)
static Future<bool> isWebViewAuthenticated() async {
return KeycloakWebViewAuthService.isAuthenticated();
}
/// Récupère l'utilisateur authentifié (compatible WebView)
static Future<User?> getCurrentWebViewUser() async {
return KeycloakWebViewAuthService.getCurrentUser();
}
/// Déconnecte l'utilisateur (compatible WebView)
static Future<bool> logoutWebView() async {
return KeycloakWebViewAuthService.logout();
}
/// Nettoie les données d'authentification WebView
static Future<void> clearWebViewAuthData() async {
return KeycloakWebViewAuthService.clearAuthData();
}
}

View File

@@ -0,0 +1,246 @@
/// Mapper de Rôles Keycloak vers UserRole
/// Convertit les rôles Keycloak existants vers notre système de rôles sophistiqué
library keycloak_role_mapper;
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Service de mapping des rôles Keycloak
class KeycloakRoleMapper {
/// Mapping des rôles Keycloak vers UserRole
static const Map<String, UserRole> _keycloakToUserRole = {
// Rôles administratifs
'ADMIN': UserRole.superAdmin,
'PRESIDENT': UserRole.orgAdmin,
// Rôles de gestion
'TRESORIER': UserRole.moderator,
'SECRETAIRE': UserRole.moderator,
'GESTIONNAIRE_MEMBRE': UserRole.moderator,
'ORGANISATEUR_EVENEMENT': UserRole.moderator,
// Rôles membres
'MEMBRE': UserRole.activeMember,
};
/// Mapping des rôles Keycloak vers permissions spécifiques
static const Map<String, List<String>> _keycloakToPermissions = {
'ADMIN': [
// Permissions Super Admin - Accès total
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'PRESIDENT': [
// Permissions Président - Gestion organisation
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.COMM_SEND_ALL,
],
'TRESORIER': [
// Permissions Trésorier - Focus finances
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'SECRETAIRE': [
// Permissions Secrétaire - Communication et membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.COMM_SEND_ALL,
PermissionMatrix.COMM_MODERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'GESTIONNAIRE_MEMBRE': [
// Permissions Gestionnaire de Membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_CREATE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'ORGANISATEUR_EVENEMENT': [
// Permissions Organisateur d'Événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'MEMBRE': [
// Permissions Membre Standard
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_PUBLIC,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
],
};
/// Mappe une liste de rôles Keycloak vers le UserRole principal
static UserRole mapToUserRole(List<String> keycloakRoles) {
// Priorité des rôles (du plus élevé au plus bas)
const List<String> rolePriority = [
'ADMIN',
'PRESIDENT',
'TRESORIER',
'SECRETAIRE',
'GESTIONNAIRE_MEMBRE',
'ORGANISATEUR_EVENEMENT',
'MEMBRE',
];
// Trouver le rôle avec la priorité la plus élevée
for (final String priorityRole in rolePriority) {
if (keycloakRoles.contains(priorityRole)) {
return _keycloakToUserRole[priorityRole] ?? UserRole.simpleMember;
}
}
// Par défaut, visiteur si aucun rôle reconnu
return UserRole.visitor;
}
/// Mappe une liste de rôles Keycloak vers les permissions
static List<String> mapToPermissions(List<String> keycloakRoles) {
final Set<String> permissions = <String>{};
// Ajouter les permissions pour chaque rôle
for (final String role in keycloakRoles) {
final List<String>? rolePermissions = _keycloakToPermissions[role];
if (rolePermissions != null) {
permissions.addAll(rolePermissions);
}
}
// Ajouter les permissions de base pour tous les utilisateurs authentifiés
permissions.add(PermissionMatrix.DASHBOARD_VIEW);
permissions.add(PermissionMatrix.MEMBERS_VIEW_OWN);
return permissions.toList();
}
/// Vérifie si un rôle Keycloak est reconnu
static bool isValidKeycloakRole(String role) {
return _keycloakToUserRole.containsKey(role);
}
/// Récupère tous les rôles Keycloak supportés
static List<String> getSupportedKeycloakRoles() {
return _keycloakToUserRole.keys.toList();
}
/// Récupère le UserRole correspondant à un rôle Keycloak spécifique
static UserRole? getUserRoleForKeycloakRole(String keycloakRole) {
return _keycloakToUserRole[keycloakRole];
}
/// Récupère les permissions pour un rôle Keycloak spécifique
static List<String> getPermissionsForKeycloakRole(String keycloakRole) {
return _keycloakToPermissions[keycloakRole] ?? [];
}
/// Analyse détaillée du mapping des rôles
static Map<String, dynamic> analyzeRoleMapping(List<String> keycloakRoles) {
final UserRole primaryRole = mapToUserRole(keycloakRoles);
final List<String> permissions = mapToPermissions(keycloakRoles);
final Map<String, List<String>> roleBreakdown = {};
for (final String role in keycloakRoles) {
if (isValidKeycloakRole(role)) {
roleBreakdown[role] = getPermissionsForKeycloakRole(role);
}
}
return {
'keycloakRoles': keycloakRoles,
'primaryRole': primaryRole.name,
'primaryRoleDisplayName': primaryRole.displayName,
'totalPermissions': permissions.length,
'permissions': permissions,
'roleBreakdown': roleBreakdown,
'unrecognizedRoles': keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList(),
};
}
/// Suggestions d'amélioration du mapping
static Map<String, dynamic> getMappingSuggestions(List<String> keycloakRoles) {
final List<String> unrecognized = keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList();
final List<String> suggestions = [];
if (unrecognized.isNotEmpty) {
suggestions.add(
'Rôles non reconnus détectés: ${unrecognized.join(", ")}. '
'Considérez ajouter ces rôles au mapping ou les ignorer.',
);
}
if (keycloakRoles.isEmpty) {
suggestions.add(
'Aucun rôle Keycloak détecté. L\'utilisateur sera traité comme visiteur.',
);
}
final UserRole primaryRole = mapToUserRole(keycloakRoles);
if (primaryRole == UserRole.visitor && keycloakRoles.isNotEmpty) {
suggestions.add(
'L\'utilisateur a des rôles Keycloak mais est mappé comme visiteur. '
'Vérifiez la configuration du mapping.',
);
}
return {
'unrecognizedRoles': unrecognized,
'suggestions': suggestions,
'mappingHealth': suggestions.isEmpty ? 'excellent' : 'needs_attention',
};
}
}

View File

@@ -1,373 +1,671 @@
/// Service d'Authentification Keycloak via WebView
///
/// Implémentation professionnelle et sécurisée de l'authentification OAuth2/OIDC
/// avec Keycloak utilisant WebView pour contourner les limitations HTTPS de flutter_appauth.
///
/// Fonctionnalités :
/// - Flow OAuth2 Authorization Code avec PKCE
/// - Gestion sécurisée des tokens JWT
/// - Support HTTP/HTTPS
/// - Gestion complète des erreurs et timeouts
/// - Validation rigoureuse des paramètres
/// - Logging détaillé pour le debugging
library keycloak_webview_auth_service;
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import 'package:http/http.dart' as http;
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../models/auth_state.dart';
import '../models/user_info.dart';
import 'package:dio/dio.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
@singleton
/// Configuration Keycloak pour l'authentification WebView
class KeycloakWebViewConfig {
/// URL de base de l'instance Keycloak
static const String baseUrl = 'http://192.168.1.145:8180';
/// Realm UnionFlow
static const String realm = 'unionflow';
/// Client ID pour l'application mobile
static const String clientId = 'unionflow-mobile';
/// URL de redirection après authentification
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
/// Scopes OAuth2 demandés
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
/// Timeout pour les requêtes HTTP (en secondes)
static const int httpTimeoutSeconds = 30;
/// Timeout pour l'authentification WebView (en secondes)
static const int authTimeoutSeconds = 300; // 5 minutes
/// Endpoints calculés
static String get authorizationEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
static String get tokenEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get userInfoEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
static String get logoutEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
static String get jwksEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/certs';
}
/// Résultat de l'authentification WebView
class WebViewAuthResult {
final String accessToken;
final String idToken;
final String? refreshToken;
final int expiresIn;
final String tokenType;
final List<String> scopes;
const WebViewAuthResult({
required this.accessToken,
required this.idToken,
this.refreshToken,
required this.expiresIn,
required this.tokenType,
required this.scopes,
});
/// Création depuis la réponse token de Keycloak
factory WebViewAuthResult.fromTokenResponse(Map<String, dynamic> response) {
return WebViewAuthResult(
accessToken: response['access_token'] ?? '',
idToken: response['id_token'] ?? '',
refreshToken: response['refresh_token'],
expiresIn: response['expires_in'] ?? 3600,
tokenType: response['token_type'] ?? 'Bearer',
scopes: (response['scope'] as String?)?.split(' ') ?? [],
);
}
}
/// Exceptions spécifiques à l'authentification WebView
class KeycloakWebViewAuthException implements Exception {
final String message;
final String? code;
final dynamic originalError;
const KeycloakWebViewAuthException(
this.message, {
this.code,
this.originalError,
});
@override
String toString() => 'KeycloakWebViewAuthException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Service d'authentification Keycloak via WebView
///
/// Implémentation complète et sécurisée du flow OAuth2 Authorization Code avec PKCE
class KeycloakWebViewAuthService {
static const String _keycloakBaseUrl = 'http://192.168.1.11:8180';
static const String _realm = 'unionflow';
static const String _clientId = 'unionflow-mobile';
static const String _redirectUrl = 'http://192.168.1.11:8080/auth/callback';
// Stockage sécurisé des tokens
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
final Dio _dio = Dio();
// Clés de stockage sécurisé
static const String _accessTokenKey = 'keycloak_webview_access_token';
static const String _idTokenKey = 'keycloak_webview_id_token';
static const String _refreshTokenKey = 'keycloak_webview_refresh_token';
static const String _userInfoKey = 'keycloak_webview_user_info';
static const String _authStateKey = 'keycloak_webview_auth_state';
// Stream pour l'état d'authentification
final _authStateController = StreamController<AuthState>.broadcast();
Stream<AuthState> get authStateStream => _authStateController.stream;
// Client HTTP avec timeout configuré
static final http.Client _httpClient = http.Client();
AuthState _currentState = const AuthState.unauthenticated();
AuthState get currentState => _currentState;
KeycloakWebViewAuthService() {
_initializeAuthState();
/// Génère un code verifier PKCE sécurisé
static String _generateCodeVerifier() {
const String charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final Random random = Random.secure();
return List.generate(128, (i) => charset[random.nextInt(charset.length)]).join();
}
Future<void> _initializeAuthState() async {
print('🔄 Initialisation du service d\'authentification WebView...');
/// Génère le code challenge PKCE à partir du verifier
static String _generateCodeChallenge(String verifier) {
final List<int> bytes = utf8.encode(verifier);
final Digest digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Génère un state sécurisé pour la protection CSRF
static String _generateState() {
final Random random = Random.secure();
final List<int> bytes = List.generate(32, (i) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
/// Construit l'URL d'autorisation Keycloak avec tous les paramètres
static Future<Map<String, String>> _buildAuthorizationUrl() async {
final String codeVerifier = _generateCodeVerifier();
final String codeChallenge = _generateCodeChallenge(codeVerifier);
final String state = _generateState();
try {
final accessToken = await _secureStorage.read(key: 'access_token');
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
final userInfo = await _getUserInfoFromToken(accessToken);
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (userInfo != null && refreshToken != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(accessToken)['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
));
return;
}
}
// Tentative de refresh si le token d'accès est expiré
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (refreshToken != null && !JwtDecoder.isExpired(refreshToken)) {
final success = await _refreshTokens();
if (success) return;
}
// Aucun token valide trouvé
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
} catch (e) {
print('❌ Erreur lors de l\'initialisation: $e');
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
}
}
Future<void> loginWithWebView(BuildContext context) async {
print('🔐 Début de la connexion Keycloak WebView...');
// Stocker les paramètres pour la validation ultérieure
await _secureStorage.write(
key: _authStateKey,
value: jsonEncode({
'code_verifier': codeVerifier,
'state': state,
'timestamp': DateTime.now().millisecondsSinceEpoch,
}),
);
try {
_updateAuthState(const AuthState.checking());
// Génération des paramètres PKCE
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
final state = _generateRandomString(32);
// Construction de l'URL d'autorisation
final authUrl = _buildAuthorizationUrl(codeChallenge, state);
print('🌐 URL d\'autorisation: $authUrl');
// Ouverture de la WebView
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (context) => KeycloakWebViewPage(
authUrl: authUrl,
redirectUrl: _redirectUrl,
),
),
);
if (result != null) {
// Traitement du code d'autorisation
await _handleAuthorizationCode(result, codeVerifier, state);
} else {
print('❌ Authentification annulée par l\'utilisateur');
_updateAuthState(const AuthState.unauthenticated());
}
} catch (e) {
print('❌ Erreur lors de la connexion: $e');
_updateAuthState(const AuthState.unauthenticated());
rethrow;
}
}
String _buildAuthorizationUrl(String codeChallenge, String state) {
final params = {
'client_id': _clientId,
'redirect_uri': _redirectUrl,
final Map<String, String> params = {
'response_type': 'code',
'scope': 'openid profile email',
'client_id': KeycloakWebViewConfig.clientId,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'scope': KeycloakWebViewConfig.scopes.join(' '),
'state': state,
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': state,
'kc_locale': 'fr',
'prompt': 'login',
};
final queryString = params.entries
final String queryString = params.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return '$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/auth?$queryString';
final String authUrl = '${KeycloakWebViewConfig.authorizationEndpoint}?$queryString';
debugPrint('🔐 URL d\'autorisation générée: $authUrl');
return {
'url': authUrl,
'state': state,
'code_verifier': codeVerifier,
};
}
/// Valide la réponse de redirection et extrait le code d'autorisation
static Future<String> _validateCallbackAndExtractCode(
String callbackUrl,
String expectedState,
) async {
debugPrint('🔍 Validation du callback: $callbackUrl');
final Uri uri = Uri.parse(callbackUrl);
// Vérifier que c'est bien notre URL de redirection
if (!callbackUrl.startsWith(KeycloakWebViewConfig.redirectUrl)) {
throw const KeycloakWebViewAuthException(
'URL de callback invalide',
code: 'INVALID_CALLBACK_URL',
);
}
// Vérifier la présence d'erreurs
final String? error = uri.queryParameters['error'];
if (error != null) {
final String? errorDescription = uri.queryParameters['error_description'];
throw KeycloakWebViewAuthException(
'Erreur d\'authentification: ${errorDescription ?? error}',
code: error,
);
}
// Valider le state pour la protection CSRF
final String? receivedState = uri.queryParameters['state'];
if (receivedState != expectedState) {
throw const KeycloakWebViewAuthException(
'State invalide - possible attaque CSRF',
code: 'INVALID_STATE',
);
}
// Extraire le code d'autorisation
final String? code = uri.queryParameters['code'];
if (code == null || code.isEmpty) {
throw const KeycloakWebViewAuthException(
'Code d\'autorisation manquant',
code: 'MISSING_AUTH_CODE',
);
}
debugPrint('✅ Code d\'autorisation extrait avec succès');
return code;
}
Future<void> _handleAuthorizationCode(String authCode, String codeVerifier, String expectedState) async {
print('🔄 Traitement du code d\'autorisation...');
/// Échange le code d'autorisation contre des tokens
static Future<WebViewAuthResult> _exchangeCodeForTokens(
String authCode,
String codeVerifier,
) async {
debugPrint('🔄 Échange du code d\'autorisation contre les tokens...');
try {
// Échange du code contre des tokens
final response = await _dio.post(
'$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/token',
data: {
'grant_type': 'authorization_code',
'client_id': _clientId,
'code': authCode,
'redirect_uri': _redirectUrl,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
if (response.statusCode == 200) {
final tokens = response.data;
await _storeTokens(tokens);
final userInfo = await _getUserInfoFromToken(tokens['access_token']);
if (userInfo != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(tokens['access_token'])['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: tokens['access_token'],
refreshToken: tokens['refresh_token'],
expiresAt: expiresAt,
));
print('✅ Authentification réussie pour: ${userInfo.email}');
final Map<String, String> body = {
'grant_type': 'authorization_code',
'client_id': KeycloakWebViewConfig.clientId,
'code': authCode,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'code_verifier': codeVerifier,
};
final http.Response response = await _httpClient
.post(
Uri.parse(KeycloakWebViewConfig.tokenEndpoint),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body,
)
.timeout(Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds));
debugPrint('📡 Réponse token endpoint: ${response.statusCode}');
if (response.statusCode != 200) {
final String errorBody = response.body;
debugPrint('❌ Erreur échange tokens: $errorBody');
Map<String, dynamic>? errorJson;
try {
errorJson = jsonDecode(errorBody);
} catch (e) {
// Ignore JSON parsing errors
}
final String errorMessage = errorJson?['error_description'] ??
errorJson?['error'] ??
'Erreur HTTP ${response.statusCode}';
throw KeycloakWebViewAuthException(
'Échec de l\'échange de tokens: $errorMessage',
code: errorJson?['error'],
);
}
final Map<String, dynamic> tokenResponse = jsonDecode(response.body);
// Valider la présence des tokens requis
if (!tokenResponse.containsKey('access_token') ||
!tokenResponse.containsKey('id_token')) {
throw const KeycloakWebViewAuthException(
'Tokens manquants dans la réponse',
code: 'MISSING_TOKENS',
);
}
debugPrint('✅ Tokens reçus avec succès');
return WebViewAuthResult.fromTokenResponse(tokenResponse);
} on TimeoutException {
throw const KeycloakWebViewAuthException(
'Timeout lors de l\'échange des tokens',
code: 'TIMEOUT',
);
} catch (e) {
print('❌ Erreur lors de l\'échange de tokens: $e');
_updateAuthState(const AuthState.unauthenticated());
rethrow;
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de l\'échange des tokens: $e',
originalError: e,
);
}
}
// Méthodes utilitaires PKCE
String _generateCodeVerifier() {
final random = Random.secure();
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
/// Stocke les tokens de manière sécurisée
static Future<void> _storeTokens(WebViewAuthResult authResult) async {
debugPrint('💾 Stockage sécurisé des tokens...');
String _generateCodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
String _generateRandomString(int length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (index) => chars[random.nextInt(chars.length)]).join();
}
Future<UserInfo?> _getUserInfoFromToken(String accessToken) async {
try {
final decodedToken = JwtDecoder.decode(accessToken);
final roles = List<String>.from(decodedToken['realm_access']?['roles'] ?? []);
final primaryRole = roles.isNotEmpty ? roles.first : 'membre';
await Future.wait([
_secureStorage.write(key: _accessTokenKey, value: authResult.accessToken),
_secureStorage.write(key: _idTokenKey, value: authResult.idToken),
if (authResult.refreshToken != null)
_secureStorage.write(key: _refreshTokenKey, value: authResult.refreshToken!),
]);
return UserInfo(
id: decodedToken['sub'] ?? '',
email: decodedToken['email'] ?? '',
firstName: decodedToken['given_name'] ?? '',
lastName: decodedToken['family_name'] ?? '',
role: primaryRole,
roles: roles,
debugPrint('✅ Tokens stockés avec succès');
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors du stockage des tokens: $e',
originalError: e,
);
}
}
/// Valide et parse un token JWT
static Map<String, dynamic> _parseAndValidateJWT(String token, String tokenType) {
try {
// Vérifier l'expiration
if (JwtDecoder.isExpired(token)) {
throw KeycloakWebViewAuthException(
'$tokenType expiré',
code: 'TOKEN_EXPIRED',
);
}
// Parser le payload
final Map<String, dynamic> payload = JwtDecoder.decode(token);
// Validations de base
if (payload['iss'] == null) {
throw const KeycloakWebViewAuthException(
'Token JWT invalide: issuer manquant',
code: 'INVALID_JWT',
);
}
// Vérifier l'issuer
final String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}';
if (payload['iss'] != expectedIssuer) {
throw KeycloakWebViewAuthException(
'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})',
code: 'INVALID_ISSUER',
);
}
debugPrint('$tokenType validé avec succès');
return payload;
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la validation du $tokenType: $e',
originalError: e,
);
}
}
/// Méthode principale d'authentification
///
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
static Future<Map<String, String>> prepareAuthentication() async {
debugPrint('🚀 Préparation de l\'authentification WebView...');
try {
// Nettoyer les données d'authentification précédentes
await clearAuthData();
// Générer l'URL d'autorisation avec PKCE
final Map<String, String> authParams = await _buildAuthorizationUrl();
debugPrint('✅ Authentification préparée avec succès');
return authParams;
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors de la préparation de l\'authentification: $e',
originalError: e,
);
}
}
/// Traite le callback de redirection et finalise l'authentification
static Future<User> handleAuthCallback(String callbackUrl) async {
debugPrint('🔄 Traitement du callback d\'authentification...');
debugPrint('📋 URL de callback: $callbackUrl');
try {
// Récupérer les paramètres d'authentification stockés
debugPrint('🔍 Récupération de l\'état d\'authentification...');
final String? authStateJson = await _secureStorage.read(key: _authStateKey);
if (authStateJson == null) {
debugPrint('❌ État d\'authentification manquant');
throw const KeycloakWebViewAuthException(
'État d\'authentification manquant',
code: 'MISSING_AUTH_STATE',
);
}
final Map<String, dynamic> authState = jsonDecode(authStateJson);
final String expectedState = authState['state'];
final String codeVerifier = authState['code_verifier'];
debugPrint('✅ État d\'authentification récupéré');
// Valider le callback et extraire le code
debugPrint('🔍 Validation du callback...');
final String authCode = await _validateCallbackAndExtractCode(
callbackUrl,
expectedState,
);
debugPrint('✅ Code d\'autorisation extrait: ${authCode.substring(0, 10)}...');
// Échanger le code contre des tokens
debugPrint('🔄 Échange du code contre les tokens...');
final WebViewAuthResult authResult = await _exchangeCodeForTokens(
authCode,
codeVerifier,
);
debugPrint('✅ Tokens reçus avec succès');
// Stocker les tokens
debugPrint('💾 Stockage des tokens...');
await _storeTokens(authResult);
debugPrint('✅ Tokens stockés');
// Créer l'utilisateur depuis les tokens
debugPrint('👤 Création de l\'utilisateur...');
final User user = await _createUserFromTokens(authResult);
debugPrint('✅ Utilisateur créé: ${user.fullName}');
// Nettoyer l'état d'authentification temporaire
await _secureStorage.delete(key: _authStateKey);
debugPrint('🎉 Authentification WebView terminée avec succès');
return user;
} catch (e, stackTrace) {
debugPrint('💥 Erreur lors du traitement du callback: $e');
debugPrint('📋 Stack trace: $stackTrace');
// Nettoyer en cas d'erreur
await _secureStorage.delete(key: _authStateKey);
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors du traitement du callback: $e',
originalError: e,
);
}
}
/// Crée un utilisateur depuis les tokens JWT
static Future<User> _createUserFromTokens(WebViewAuthResult authResult) async {
debugPrint('👤 Création de l\'utilisateur depuis les tokens...');
try {
// Parser et valider les tokens
final Map<String, dynamic> accessTokenPayload = _parseAndValidateJWT(
authResult.accessToken,
'Access Token',
);
final Map<String, dynamic> idTokenPayload = _parseAndValidateJWT(
authResult.idToken,
'ID Token',
);
// Extraire les informations utilisateur
final String userId = idTokenPayload['sub'] ?? '';
final String email = idTokenPayload['email'] ?? '';
final String firstName = idTokenPayload['given_name'] ?? '';
final String lastName = idTokenPayload['family_name'] ?? '';
if (userId.isEmpty || email.isEmpty) {
throw const KeycloakWebViewAuthException(
'Informations utilisateur manquantes dans les tokens',
code: 'MISSING_USER_INFO',
);
}
// Extraire les rôles Keycloak
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
// Mapper vers notre système de rôles
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
// Créer l'utilisateur
final User user = User(
id: userId,
email: email,
firstName: firstName,
lastName: lastName,
primaryRole: primaryRole,
organizationContexts: const [],
additionalPermissions: permissions,
revokedPermissions: const [],
preferences: const UserPreferences(
language: 'fr',
theme: 'system',
notificationsEnabled: true,
emailNotifications: true,
pushNotifications: true,
dashboardLayout: 'adaptive',
timezone: 'Europe/Paris',
),
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
isActive: true,
);
// Stocker les informations utilisateur
await _secureStorage.write(
key: _userInfoKey,
value: jsonEncode(user.toJson()),
);
debugPrint('✅ Utilisateur créé: ${user.fullName} (${user.primaryRole.displayName})');
return user;
} catch (e) {
print('❌ Erreur lors de l\'extraction des infos utilisateur: $e');
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la création de l\'utilisateur: $e',
originalError: e,
);
}
}
/// Extrait les rôles Keycloak depuis le payload du token
static List<String> _extractKeycloakRoles(Map<String, dynamic> tokenPayload) {
try {
final List<String> roles = <String>[];
// Rôles realm
final Map<String, dynamic>? realmAccess = tokenPayload['realm_access'];
if (realmAccess != null && realmAccess['roles'] is List) {
roles.addAll(List<String>.from(realmAccess['roles']));
}
// Rôles client
final Map<String, dynamic>? resourceAccess = tokenPayload['resource_access'];
if (resourceAccess != null) {
final Map<String, dynamic>? clientAccess = resourceAccess[KeycloakWebViewConfig.clientId];
if (clientAccess != null && clientAccess['roles'] is List) {
roles.addAll(List<String>.from(clientAccess['roles']));
}
}
// Filtrer les rôles système
return roles.where((role) =>
!role.startsWith('default-roles-') &&
role != 'offline_access' &&
role != 'uma_authorization'
).toList();
} catch (e) {
debugPrint('💥 Erreur extraction rôles: $e');
return [];
}
}
/// Nettoie toutes les données d'authentification
static Future<void> clearAuthData() async {
debugPrint('🧹 Nettoyage des données d\'authentification...');
try {
await Future.wait([
_secureStorage.delete(key: _accessTokenKey),
_secureStorage.delete(key: _idTokenKey),
_secureStorage.delete(key: _refreshTokenKey),
_secureStorage.delete(key: _userInfoKey),
_secureStorage.delete(key: _authStateKey),
]);
debugPrint('✅ Données d\'authentification nettoyées');
} catch (e) {
debugPrint('⚠️ Erreur lors du nettoyage: $e');
}
}
/// Vérifie si l'utilisateur est authentifié
static Future<bool> isAuthenticated() async {
try {
final String? accessToken = await _secureStorage.read(key: _accessTokenKey);
if (accessToken == null) {
return false;
}
// Vérifier si le token est expiré
return !JwtDecoder.isExpired(accessToken);
} catch (e) {
debugPrint('💥 Erreur vérification authentification: $e');
return false;
}
}
/// Récupère l'utilisateur authentifié
static Future<User?> getCurrentUser() async {
try {
final String? userInfoJson = await _secureStorage.read(key: _userInfoKey);
if (userInfoJson == null) {
return null;
}
final Map<String, dynamic> userJson = jsonDecode(userInfoJson);
return User.fromJson(userJson);
} catch (e) {
debugPrint('💥 Erreur récupération utilisateur: $e');
return null;
}
}
Future<void> _storeTokens(Map<String, dynamic> tokens) async {
await _secureStorage.write(key: 'access_token', value: tokens['access_token']);
await _secureStorage.write(key: 'refresh_token', value: tokens['refresh_token']);
if (tokens['id_token'] != null) {
await _secureStorage.write(key: 'id_token', value: tokens['id_token']);
}
}
/// Déconnecte l'utilisateur
static Future<bool> logout() async {
debugPrint('🚪 Déconnexion de l\'utilisateur...');
Future<bool> _refreshTokens() async {
try {
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (refreshToken == null) return false;
// Nettoyer les données locales
await clearAuthData();
final response = await _dio.post(
'$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/token',
data: {
'grant_type': 'refresh_token',
'client_id': _clientId,
'refresh_token': refreshToken,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
debugPrint('✅ Déconnexion réussie');
return true;
if (response.statusCode == 200) {
await _storeTokens(response.data);
final userInfo = await _getUserInfoFromToken(response.data['access_token']);
if (userInfo != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(response.data['access_token'])['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
expiresAt: expiresAt,
));
return true;
}
}
} catch (e) {
print(' Erreur lors du refresh: $e');
}
return false;
}
Future<void> logout() async {
print('🚪 Déconnexion...');
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
}
Future<void> _clearTokens() async {
await _secureStorage.delete(key: 'access_token');
await _secureStorage.delete(key: 'refresh_token');
await _secureStorage.delete(key: 'id_token');
}
void _updateAuthState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}
// Page WebView pour l'authentification
class KeycloakWebViewPage extends StatefulWidget {
final String authUrl;
final String redirectUrl;
const KeycloakWebViewPage({
Key? key,
required this.authUrl,
required this.redirectUrl,
}) : super(key: key);
@override
State<KeycloakWebViewPage> createState() => _KeycloakWebViewPageState();
}
class _KeycloakWebViewPageState extends State<KeycloakWebViewPage> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_initializeWebView();
}
void _initializeWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent('Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36')
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
print('🌐 Navigation vers: ${request.url}');
if (request.url.startsWith(widget.redirectUrl)) {
// Extraction du code d'autorisation
final uri = Uri.parse(request.url);
final code = uri.queryParameters['code'];
if (code != null) {
print('✅ Code d\'autorisation reçu: $code');
Navigator.of(context).pop(code);
} else {
print('❌ Aucun code d\'autorisation trouvé');
Navigator.of(context).pop();
}
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onWebResourceError: (WebResourceError error) {
print('❌ Erreur WebView: ${error.description}');
print('❌ Code d\'erreur: ${error.errorCode}');
print('❌ URL qui a échoué: ${error.url}');
},
),
);
// Chargement avec gestion d'erreur
_loadUrlWithRetry();
}
Future<void> _loadUrlWithRetry() async {
try {
await _controller.loadRequest(Uri.parse(widget.authUrl));
} catch (e) {
print('❌ Erreur lors du chargement: $e');
// Retry avec une approche différente si nécessaire
debugPrint('💥 Erreur déconnexion: $e');
return false;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Connexion Keycloak'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: WebViewWidget(controller: _controller),
);
}
}

View File

@@ -0,0 +1,375 @@
/// Moteur de permissions ultra-performant avec cache intelligent
/// Vérifications contextuelles et audit trail intégré
library permission_engine;
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Moteur de permissions haute performance avec cache multi-niveaux
///
/// Fonctionnalités :
/// - Cache mémoire ultra-rapide avec TTL
/// - Vérifications contextuelles avancées
/// - Audit trail automatique
/// - Support des permissions héritées
/// - Invalidation intelligente du cache
class PermissionEngine {
static final PermissionEngine _instance = PermissionEngine._internal();
factory PermissionEngine() => _instance;
PermissionEngine._internal();
/// Cache mémoire des permissions avec TTL
static final Map<String, _CachedPermission> _permissionCache = {};
/// Cache des permissions effectives par utilisateur
static final Map<String, _CachedUserPermissions> _userPermissionsCache = {};
/// Durée de vie du cache (5 minutes par défaut)
static const Duration _defaultCacheTTL = Duration(minutes: 5);
/// Durée de vie du cache pour les super admins (plus long)
static const Duration _superAdminCacheTTL = Duration(minutes: 15);
/// Compteur de hits/miss du cache pour monitoring
static int _cacheHits = 0;
static int _cacheMisses = 0;
/// Stream pour les événements d'audit
static final StreamController<PermissionAuditEvent> _auditController =
StreamController<PermissionAuditEvent>.broadcast();
/// Stream des événements d'audit
static Stream<PermissionAuditEvent> get auditStream => _auditController.stream;
/// Vérifie si un utilisateur a une permission spécifique
///
/// [user] - Utilisateur à vérifier
/// [permission] - Permission à vérifier
/// [organizationId] - Contexte organisationnel optionnel
/// [auditLog] - Activer l'audit trail (défaut: true)
static Future<bool> hasPermission(
User user,
String permission, {
String? organizationId,
bool auditLog = true,
}) async {
final cacheKey = _generateCacheKey(user.id, permission, organizationId);
// Vérification du cache
final cachedResult = _getCachedPermission(cacheKey);
if (cachedResult != null) {
_cacheHits++;
if (auditLog && !cachedResult.result) {
_logAuditEvent(user, permission, false, 'CACHED_DENIED', organizationId);
}
return cachedResult.result;
}
_cacheMisses++;
// Calcul de la permission
final result = await _computePermission(user, permission, organizationId);
// Mise en cache
_cachePermission(cacheKey, result, user.primaryRole);
// Audit trail
if (auditLog) {
_logAuditEvent(
user,
permission,
result,
result ? 'GRANTED' : 'DENIED',
organizationId,
);
}
return result;
}
/// Vérifie plusieurs permissions en une seule fois
static Future<Map<String, bool>> hasPermissions(
User user,
List<String> permissions, {
String? organizationId,
bool auditLog = true,
}) async {
final results = <String, bool>{};
// Traitement en parallèle pour les performances
final futures = permissions.map((permission) =>
hasPermission(user, permission, organizationId: organizationId, auditLog: auditLog)
.then((result) => MapEntry(permission, result))
);
final entries = await Future.wait(futures);
for (final entry in entries) {
results[entry.key] = entry.value;
}
return results;
}
/// Obtient toutes les permissions effectives d'un utilisateur
static Future<List<String>> getEffectivePermissions(
User user, {
String? organizationId,
}) async {
final cacheKey = '${user.id}_effective_${organizationId ?? 'global'}';
// Vérification du cache utilisateur
final cachedUserPermissions = _getCachedUserPermissions(cacheKey);
if (cachedUserPermissions != null) {
_cacheHits++;
return cachedUserPermissions.permissions;
}
_cacheMisses++;
// Calcul des permissions effectives
final permissions = user.getEffectivePermissions(organizationId: organizationId);
// Mise en cache
_cacheUserPermissions(cacheKey, permissions, user.primaryRole);
return permissions;
}
/// Vérifie si un utilisateur peut effectuer une action sur un domaine
static Future<bool> canPerformAction(
User user,
String domain,
String action, {
String scope = 'own',
String? organizationId,
}) async {
final permission = '$domain.$action.$scope';
return hasPermission(user, permission, organizationId: organizationId);
}
/// Invalide le cache pour un utilisateur spécifique
static void invalidateUserCache(String userId) {
final keysToRemove = <String>[];
// Invalider le cache des permissions
for (final key in _permissionCache.keys) {
if (key.startsWith('${userId}_')) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_permissionCache.remove(key);
}
// Invalider le cache des permissions utilisateur
final userKeysToRemove = <String>[];
for (final key in _userPermissionsCache.keys) {
if (key.startsWith('${userId}_')) {
userKeysToRemove.add(key);
}
}
for (final key in userKeysToRemove) {
_userPermissionsCache.remove(key);
}
debugPrint('Cache invalidé pour l\'utilisateur: $userId');
}
/// Invalide tout le cache
static void invalidateAllCache() {
_permissionCache.clear();
_userPermissionsCache.clear();
debugPrint('Cache complet invalidé');
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getCacheStats() {
final totalRequests = _cacheHits + _cacheMisses;
final hitRate = totalRequests > 0 ? (_cacheHits / totalRequests * 100) : 0.0;
return {
'cacheHits': _cacheHits,
'cacheMisses': _cacheMisses,
'hitRate': hitRate.toStringAsFixed(2),
'permissionCacheSize': _permissionCache.length,
'userPermissionsCacheSize': _userPermissionsCache.length,
};
}
/// Nettoie le cache expiré
static void cleanExpiredCache() {
final now = DateTime.now();
// Nettoyer le cache des permissions
_permissionCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
// Nettoyer le cache des permissions utilisateur
_userPermissionsCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
debugPrint('Cache expiré nettoyé');
}
// === MÉTHODES PRIVÉES ===
/// Calcule une permission sans cache
static Future<bool> _computePermission(
User user,
String permission,
String? organizationId,
) async {
// Vérification des permissions publiques
if (PermissionMatrix.isPublicPermission(permission)) {
return true;
}
// Vérification utilisateur actif
if (!user.isActive) return false;
// Vérification directe de l'utilisateur
if (user.hasPermission(permission, organizationId: organizationId)) {
return true;
}
// Vérifications contextuelles avancées
return _checkContextualPermissions(user, permission, organizationId);
}
/// Vérifications contextuelles avancées
static Future<bool> _checkContextualPermissions(
User user,
String permission,
String? organizationId,
) async {
// Logique contextuelle future (intégration avec le serveur)
// Pour l'instant, retourne false
return false;
}
/// Génère une clé de cache unique
static String _generateCacheKey(String userId, String permission, String? organizationId) {
return '${userId}_${permission}_${organizationId ?? 'global'}';
}
/// Obtient une permission depuis le cache
static _CachedPermission? _getCachedPermission(String key) {
final cached = _permissionCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_permissionCache.remove(key);
}
return null;
}
/// Met en cache une permission
static void _cachePermission(String key, bool result, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_permissionCache[key] = _CachedPermission(
result: result,
expiresAt: DateTime.now().add(ttl),
);
}
/// Obtient les permissions utilisateur depuis le cache
static _CachedUserPermissions? _getCachedUserPermissions(String key) {
final cached = _userPermissionsCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_userPermissionsCache.remove(key);
}
return null;
}
/// Met en cache les permissions utilisateur
static void _cacheUserPermissions(String key, List<String> permissions, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_userPermissionsCache[key] = _CachedUserPermissions(
permissions: permissions,
expiresAt: DateTime.now().add(ttl),
);
}
/// Enregistre un événement d'audit
static void _logAuditEvent(
User user,
String permission,
bool granted,
String reason,
String? organizationId,
) {
final event = PermissionAuditEvent(
userId: user.id,
userEmail: user.email,
permission: permission,
granted: granted,
reason: reason,
organizationId: organizationId,
timestamp: DateTime.now(),
);
_auditController.add(event);
}
}
/// Classe pour les permissions mises en cache
class _CachedPermission {
final bool result;
final DateTime expiresAt;
_CachedPermission({required this.result, required this.expiresAt});
}
/// Classe pour les permissions utilisateur mises en cache
class _CachedUserPermissions {
final List<String> permissions;
final DateTime expiresAt;
_CachedUserPermissions({required this.permissions, required this.expiresAt});
}
/// Événement d'audit des permissions
class PermissionAuditEvent {
final String userId;
final String userEmail;
final String permission;
final bool granted;
final String reason;
final String? organizationId;
final DateTime timestamp;
PermissionAuditEvent({
required this.userId,
required this.userEmail,
required this.permission,
required this.granted,
required this.reason,
this.organizationId,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'userId': userId,
'userEmail': userEmail,
'permission': permission,
'granted': granted,
'reason': reason,
'organizationId': organizationId,
'timestamp': timestamp.toIso8601String(),
};
}
}

View File

@@ -1,314 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/user_info.dart';
import 'auth_service.dart';
/// Service de gestion des permissions et rôles utilisateurs
/// Basé sur le système de rôles du serveur UnionFlow
class PermissionService {
static final PermissionService _instance = PermissionService._internal();
factory PermissionService() => _instance;
PermissionService._internal();
// Pour l'instant, on simule un utilisateur admin pour les tests
// TODO: Intégrer avec le vrai AuthService une fois l'authentification implémentée
AuthService? _authService;
// Simulation d'un utilisateur admin pour les tests
final UserInfo _mockUser = const UserInfo(
id: 'admin-001',
email: 'admin@unionflow.ci',
firstName: 'Administrateur',
lastName: 'Test',
role: 'ADMIN',
isActive: true,
);
/// Rôles système disponibles
static const String roleAdmin = 'ADMIN';
static const String roleSuperAdmin = 'SUPER_ADMIN';
static const String roleGestionnaireMembre = 'GESTIONNAIRE_MEMBRE';
static const String roleTresorier = 'TRESORIER';
static const String roleGestionnaireEvenement = 'GESTIONNAIRE_EVENEMENT';
static const String roleGestionnaireAide = 'GESTIONNAIRE_AIDE';
static const String roleGestionnaireFinance = 'GESTIONNAIRE_FINANCE';
static const String roleMembre = 'MEMBER';
static const String rolePresident = 'PRESIDENT';
/// Obtient l'utilisateur actuellement connecté
UserInfo? get currentUser => _authService?.currentUser ?? _mockUser;
/// Vérifie si l'utilisateur est authentifié
bool get isAuthenticated => _authService?.isAuthenticated ?? true;
/// Obtient le rôle de l'utilisateur actuel
String? get currentUserRole => currentUser?.role.toUpperCase();
/// Vérifie si l'utilisateur a un rôle spécifique
bool hasRole(String role) {
if (!isAuthenticated || currentUserRole == null) {
return false;
}
return currentUserRole == role.toUpperCase();
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
if (!isAuthenticated || currentUserRole == null) {
return false;
}
return roles.any((role) => currentUserRole == role.toUpperCase());
}
/// Vérifie si l'utilisateur est un administrateur
bool get isAdmin => hasRole(roleAdmin);
/// Vérifie si l'utilisateur est un super administrateur
bool get isSuperAdmin => hasRole(roleSuperAdmin);
/// Vérifie si l'utilisateur est un membre simple
bool get isMember => hasRole(roleMembre);
/// Vérifie si l'utilisateur est un gestionnaire
bool get isGestionnaire => hasAnyRole([
roleGestionnaireMembre,
roleGestionnaireEvenement,
roleGestionnaireAide,
roleGestionnaireFinance,
]);
/// Vérifie si l'utilisateur est un trésorier
bool get isTresorier => hasRole(roleTresorier);
/// Vérifie si l'utilisateur est un président
bool get isPresident => hasRole(rolePresident);
// ========== PERMISSIONS SPÉCIFIQUES AUX MEMBRES ==========
/// Peut gérer les membres (créer, modifier, supprimer)
bool get canManageMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre, rolePresident]);
}
/// Peut créer de nouveaux membres
bool get canCreateMembers {
return canManageMembers;
}
/// Peut modifier les informations des membres
bool get canEditMembers {
return canManageMembers;
}
/// Peut supprimer/désactiver des membres
bool get canDeleteMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, rolePresident]);
}
/// Peut voir les détails complets des membres
bool get canViewMemberDetails {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut voir les informations de contact des membres
bool get canViewMemberContacts {
return canViewMemberDetails;
}
/// Peut exporter les données des membres
bool get canExportMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre]);
}
/// Peut importer des données de membres
bool get canImportMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin]);
}
/// Peut appeler les membres
bool get canCallMembers {
return canViewMemberContacts;
}
/// Peut envoyer des messages aux membres
bool get canMessageMembers {
return canViewMemberContacts;
}
/// Peut voir les statistiques des membres
bool get canViewMemberStats {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut valider les nouveaux membres
bool get canValidateMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre]);
}
// ========== PERMISSIONS GÉNÉRALES ==========
/// Peut gérer les finances
bool get canManageFinances {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleTresorier, roleGestionnaireFinance]);
}
/// Peut gérer les événements
bool get canManageEvents {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireEvenement]);
}
/// Peut gérer les aides
bool get canManageAides {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireAide]);
}
/// Peut voir les rapports
bool get canViewReports {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut gérer l'organisation
bool get canManageOrganization {
return hasAnyRole([roleAdmin, roleSuperAdmin]);
}
// ========== MÉTHODES UTILITAIRES ==========
/// Obtient le nom d'affichage du rôle
String getRoleDisplayName(String? role) {
if (role == null) return 'Invité';
switch (role.toUpperCase()) {
case roleAdmin:
return 'Administrateur';
case roleSuperAdmin:
return 'Super Administrateur';
case roleGestionnaireMembre:
return 'Gestionnaire Membres';
case roleTresorier:
return 'Trésorier';
case roleGestionnaireEvenement:
return 'Gestionnaire Événements';
case roleGestionnaireAide:
return 'Gestionnaire Aides';
case roleGestionnaireFinance:
return 'Gestionnaire Finances';
case rolePresident:
return 'Président';
case roleMembre:
return 'Membre';
default:
return role;
}
}
/// Obtient la couleur associée au rôle
String getRoleColor(String? role) {
if (role == null) return '#9E9E9E';
switch (role.toUpperCase()) {
case roleAdmin:
return '#FF5722';
case roleSuperAdmin:
return '#E91E63';
case roleGestionnaireMembre:
return '#2196F3';
case roleTresorier:
return '#4CAF50';
case roleGestionnaireEvenement:
return '#FF9800';
case roleGestionnaireAide:
return '#9C27B0';
case roleGestionnaireFinance:
return '#00BCD4';
case rolePresident:
return '#FFD700';
case roleMembre:
return '#607D8B';
default:
return '#9E9E9E';
}
}
/// Obtient l'icône associée au rôle
String getRoleIcon(String? role) {
if (role == null) return 'person';
switch (role.toUpperCase()) {
case roleAdmin:
return 'admin_panel_settings';
case roleSuperAdmin:
return 'security';
case roleGestionnaireMembre:
return 'people';
case roleTresorier:
return 'account_balance';
case roleGestionnaireEvenement:
return 'event';
case roleGestionnaireAide:
return 'volunteer_activism';
case roleGestionnaireFinance:
return 'monetization_on';
case rolePresident:
return 'star';
case roleMembre:
return 'person';
default:
return 'person';
}
}
/// Vérifie les permissions et lance une exception si non autorisé
void requirePermission(bool hasPermission, [String? message]) {
if (!hasPermission) {
throw PermissionDeniedException(
message ?? 'Vous n\'avez pas les permissions nécessaires pour cette action'
);
}
}
/// Vérifie les permissions et retourne un message d'erreur si non autorisé
String? checkPermission(bool hasPermission, [String? message]) {
if (!hasPermission) {
return message ?? 'Permissions insuffisantes';
}
return null;
}
/// Log des actions pour audit (en mode debug uniquement)
void logAction(String action, {Map<String, dynamic>? details}) {
if (kDebugMode) {
print('🔐 PermissionService: $action by ${currentUser?.fullName} ($currentUserRole)');
if (details != null) {
print(' Details: $details');
}
}
}
}
/// Exception lancée quand une permission est refusée
class PermissionDeniedException implements Exception {
final String message;
const PermissionDeniedException(this.message);
@override
String toString() => 'PermissionDeniedException: $message';
}

View File

@@ -1,70 +0,0 @@
import 'dart:async';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
/// Service d'authentification temporaire pour test sans dépendances
class TempAuthService {
final _authStateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unknown();
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
Future<void> initialize() async {
_updateState(const AuthState.checking());
// Simuler une vérification
await Future.delayed(const Duration(seconds: 2));
_updateState(const AuthState.unauthenticated());
}
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Simulation d'appel API
await Future.delayed(const Duration(seconds: 1));
// Vérification simple pour la démo
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
final user = UserInfo(
id: '1',
email: request.email,
firstName: 'Admin',
lastName: 'UnionFlow',
role: 'admin',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token',
refreshToken: 'fake_refresh_token',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else {
throw Exception('Identifiants invalides');
}
} catch (e) {
_updateState(AuthState.error(e.toString()));
}
}
Future<void> logout() async {
_updateState(const AuthState.unauthenticated());
}
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}

View File

@@ -1,86 +0,0 @@
import 'dart:async';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
/// Service d'authentification ultra-simple sans aucune dépendance externe
class UltraSimpleAuthService {
final _authStateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unknown();
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
Future<void> initialize() async {
_updateState(const AuthState.checking());
// Simuler une vérification
await Future.delayed(const Duration(seconds: 2));
_updateState(const AuthState.unauthenticated());
}
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Simulation d'appel API
await Future.delayed(const Duration(seconds: 1));
// Vérification simple pour la démo
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
final user = UserInfo(
id: '1',
email: request.email,
firstName: 'Admin',
lastName: 'UnionFlow',
role: 'admin',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else if (request.email == 'president@lions.org' && request.password == 'admin123') {
final user = UserInfo(
id: '2',
email: request.email,
firstName: 'Jean',
lastName: 'Dupont',
role: 'président',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else {
throw Exception('Identifiants invalides');
}
} catch (e) {
_updateState(AuthState.error(e.toString()));
}
}
Future<void> logout() async {
_updateState(const AuthState.unauthenticated());
}
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}