Authentification stable - WIP
This commit is contained in:
@@ -1,203 +1,460 @@
|
||||
import 'dart:async';
|
||||
/// BLoC d'authentification Keycloak adaptatif avec gestion des rôles
|
||||
/// Support Keycloak avec contextes multi-organisations et états sophistiqués
|
||||
library auth_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/auth_api_service.dart';
|
||||
import 'auth_event.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/user_role.dart';
|
||||
import '../services/permission_engine.dart';
|
||||
import '../services/keycloak_auth_service.dart';
|
||||
import '../../cache/dashboard_cache_manager.dart';
|
||||
|
||||
/// BLoC pour gérer l'authentification
|
||||
@singleton
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final AuthService _authService;
|
||||
late StreamSubscription<AuthState> _authStateSubscription;
|
||||
// === ÉVÉNEMENTS ===
|
||||
|
||||
AuthBloc(this._authService) : super(const AuthState.unknown()) {
|
||||
// Écouter les changements d'état du service
|
||||
_authStateSubscription = _authService.authStateStream.listen(
|
||||
(authState) => add(AuthStateChanged(authState)),
|
||||
/// Événements d'authentification
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Événement de connexion Keycloak
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
const AuthLoginRequested();
|
||||
}
|
||||
|
||||
/// Événement de déconnexion
|
||||
class AuthLogoutRequested extends AuthEvent {
|
||||
const AuthLogoutRequested();
|
||||
}
|
||||
|
||||
/// Événement de changement de contexte organisationnel
|
||||
class AuthOrganizationContextChanged extends AuthEvent {
|
||||
final String organizationId;
|
||||
|
||||
const AuthOrganizationContextChanged(this.organizationId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [organizationId];
|
||||
}
|
||||
|
||||
/// Événement de rafraîchissement du token
|
||||
class AuthTokenRefreshRequested extends AuthEvent {
|
||||
const AuthTokenRefreshRequested();
|
||||
}
|
||||
|
||||
/// Événement de vérification de l'état d'authentification
|
||||
class AuthStatusChecked extends AuthEvent {
|
||||
const AuthStatusChecked();
|
||||
}
|
||||
|
||||
/// Événement de mise à jour du profil utilisateur
|
||||
class AuthUserProfileUpdated extends AuthEvent {
|
||||
final User updatedUser;
|
||||
|
||||
const AuthUserProfileUpdated(this.updatedUser);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [updatedUser];
|
||||
}
|
||||
|
||||
/// Événement de callback WebView
|
||||
class AuthWebViewCallback extends AuthEvent {
|
||||
final String callbackUrl;
|
||||
|
||||
const AuthWebViewCallback(this.callbackUrl);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [callbackUrl];
|
||||
}
|
||||
|
||||
// === ÉTATS ===
|
||||
|
||||
/// États d'authentification
|
||||
abstract class AuthState extends Equatable {
|
||||
const AuthState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class AuthInitial extends AuthState {
|
||||
const AuthInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class AuthLoading extends AuthState {
|
||||
const AuthLoading();
|
||||
}
|
||||
|
||||
/// État authentifié avec contexte riche
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final User user;
|
||||
final String? currentOrganizationId;
|
||||
final UserRole effectiveRole;
|
||||
final List<String> effectivePermissions;
|
||||
final DateTime authenticatedAt;
|
||||
final String? accessToken;
|
||||
|
||||
const AuthAuthenticated({
|
||||
required this.user,
|
||||
this.currentOrganizationId,
|
||||
required this.effectiveRole,
|
||||
required this.effectivePermissions,
|
||||
required this.authenticatedAt,
|
||||
this.accessToken,
|
||||
});
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission
|
||||
bool hasPermission(String permission) {
|
||||
return effectivePermissions.contains(permission);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur peut effectuer une action
|
||||
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
|
||||
final permission = '$domain.$action.$scope';
|
||||
return hasPermission(permission);
|
||||
}
|
||||
|
||||
/// Obtient le contexte organisationnel actuel
|
||||
UserOrganizationContext? get currentOrganizationContext {
|
||||
if (currentOrganizationId == null) return null;
|
||||
return user.getOrganizationContext(currentOrganizationId!);
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
AuthAuthenticated copyWith({
|
||||
User? user,
|
||||
String? currentOrganizationId,
|
||||
UserRole? effectiveRole,
|
||||
List<String>? effectivePermissions,
|
||||
DateTime? authenticatedAt,
|
||||
String? accessToken,
|
||||
}) {
|
||||
return AuthAuthenticated(
|
||||
user: user ?? this.user,
|
||||
currentOrganizationId: currentOrganizationId ?? this.currentOrganizationId,
|
||||
effectiveRole: effectiveRole ?? this.effectiveRole,
|
||||
effectivePermissions: effectivePermissions ?? this.effectivePermissions,
|
||||
authenticatedAt: authenticatedAt ?? this.authenticatedAt,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
user,
|
||||
currentOrganizationId,
|
||||
effectiveRole,
|
||||
effectivePermissions,
|
||||
authenticatedAt,
|
||||
accessToken,
|
||||
];
|
||||
}
|
||||
|
||||
// Gestionnaires d'événements
|
||||
on<AuthInitializeRequested>(_onInitializeRequested);
|
||||
/// État non authentifié
|
||||
class AuthUnauthenticated extends AuthState {
|
||||
final String? message;
|
||||
|
||||
const AuthUnauthenticated({this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
|
||||
const AuthError({
|
||||
required this.message,
|
||||
this.errorCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, errorCode];
|
||||
}
|
||||
|
||||
/// État indiquant qu'une WebView d'authentification est requise
|
||||
class AuthWebViewRequired extends AuthState {
|
||||
final String authUrl;
|
||||
final String state;
|
||||
final String codeVerifier;
|
||||
|
||||
const AuthWebViewRequired({
|
||||
required this.authUrl,
|
||||
required this.state,
|
||||
required this.codeVerifier,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [authUrl, state, codeVerifier];
|
||||
}
|
||||
|
||||
// === BLOC ===
|
||||
|
||||
/// BLoC d'authentification adaptatif
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc() : super(const AuthInitial()) {
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthOrganizationContextChanged>(_onOrganizationContextChanged);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
on<AuthSessionExpired>(_onSessionExpired);
|
||||
on<AuthStatusCheckRequested>(_onStatusCheckRequested);
|
||||
on<AuthErrorCleared>(_onErrorCleared);
|
||||
on<AuthStateChanged>(_onStateChanged);
|
||||
on<AuthStatusChecked>(_onStatusChecked);
|
||||
on<AuthUserProfileUpdated>(_onUserProfileUpdated);
|
||||
on<AuthWebViewCallback>(_onWebViewCallback);
|
||||
}
|
||||
|
||||
/// Initialisation de l'authentification
|
||||
Future<void> _onInitializeRequested(
|
||||
AuthInitializeRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthState.checking());
|
||||
|
||||
try {
|
||||
await _authService.initialize();
|
||||
} catch (e) {
|
||||
emit(AuthState.error('Erreur d\'initialisation: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion de la connexion
|
||||
/// Gère la demande de connexion Keycloak via WebView
|
||||
///
|
||||
/// Cette méthode prépare l'authentification WebView et émet un état spécial
|
||||
/// pour indiquer qu'une WebView doit être ouverte
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, errorMessage: null));
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
await _authService.login(event.loginRequest);
|
||||
// L'état sera mis à jour par le stream du service
|
||||
} on AuthApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: e.message,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: 'Erreur de connexion: $e',
|
||||
debugPrint('🔐 Préparation authentification Keycloak WebView...');
|
||||
|
||||
// Préparer l'authentification WebView
|
||||
final Map<String, String> authParams = await KeycloakAuthService.prepareWebViewAuthentication();
|
||||
|
||||
debugPrint('✅ Authentification WebView préparée');
|
||||
|
||||
// Émettre un état spécial pour indiquer qu'une WebView doit être ouverte
|
||||
debugPrint('🚀 Émission de l\'état AuthWebViewRequired...');
|
||||
emit(AuthWebViewRequired(
|
||||
authUrl: authParams['url']!,
|
||||
state: authParams['state']!,
|
||||
codeVerifier: authParams['code_verifier']!,
|
||||
));
|
||||
debugPrint('✅ État AuthWebViewRequired émis');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur préparation authentification Keycloak: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de préparation: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion de la déconnexion
|
||||
/// Traite le callback WebView et finalise l'authentification
|
||||
Future<void> _onWebViewCallback(
|
||||
AuthWebViewCallback event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🔄 Traitement callback WebView...');
|
||||
|
||||
// Traiter le callback et récupérer l'utilisateur
|
||||
final User user = await KeycloakAuthService.handleWebViewCallback(event.callbackUrl);
|
||||
|
||||
debugPrint('👤 Utilisateur récupéré: ${user.fullName} (${user.primaryRole.displayName})');
|
||||
|
||||
// Calculer les permissions effectives
|
||||
debugPrint('🔐 Calcul des permissions effectives...');
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
debugPrint('✅ Permissions effectives calculées: ${effectivePermissions.length} permissions');
|
||||
|
||||
// Invalider le cache pour forcer le rechargement
|
||||
debugPrint('🧹 Invalidation du cache pour le rôle ${user.primaryRole.displayName}...');
|
||||
await DashboardCacheManager.invalidateForRole(user.primaryRole);
|
||||
debugPrint('✅ Cache invalidé');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: '', // Token géré par KeycloakWebViewAuthService
|
||||
));
|
||||
|
||||
debugPrint('🎉 Authentification complète réussie');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de connexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère la demande de déconnexion Keycloak
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
await _authService.logout();
|
||||
// L'état sera mis à jour par le stream du service
|
||||
} catch (e) {
|
||||
// Même en cas d'erreur, on considère que la déconnexion locale a réussi
|
||||
emit(const AuthState.unauthenticated());
|
||||
debugPrint('🚪 Démarrage déconnexion Keycloak...');
|
||||
|
||||
// Déconnexion Keycloak
|
||||
final logoutSuccess = await KeycloakAuthService.logout();
|
||||
|
||||
if (!logoutSuccess) {
|
||||
debugPrint('⚠️ Déconnexion Keycloak partielle');
|
||||
}
|
||||
|
||||
// Nettoyer le cache local
|
||||
await DashboardCacheManager.clear();
|
||||
|
||||
// Invalider le cache des permissions
|
||||
if (state is AuthAuthenticated) {
|
||||
final authState = state as AuthAuthenticated;
|
||||
PermissionEngine.invalidateUserCache(authState.user.id);
|
||||
}
|
||||
|
||||
debugPrint('✅ Déconnexion complète réussie');
|
||||
emit(const AuthUnauthenticated(message: 'Déconnexion réussie'));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur déconnexion: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de déconnexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion du rafraîchissement de token
|
||||
/// Gère le changement de contexte organisationnel
|
||||
Future<void> _onOrganizationContextChanged(
|
||||
AuthOrganizationContextChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
// Recalculer le rôle effectif et les permissions
|
||||
final effectiveRole = currentState.user.getRoleInOrganization(event.organizationId);
|
||||
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
currentState.user,
|
||||
organizationId: event.organizationId,
|
||||
);
|
||||
|
||||
// Invalider le cache pour le nouveau contexte
|
||||
PermissionEngine.invalidateUserCache(currentState.user.id);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
currentOrganizationId: event.organizationId,
|
||||
effectiveRole: effectiveRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de changement de contexte: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le rafraîchissement du token
|
||||
Future<void> _onTokenRefreshRequested(
|
||||
AuthTokenRefreshRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
// Le rafraîchissement est géré automatiquement par le service
|
||||
// Cet événement peut être utilisé pour forcer un rafraîchissement manuel
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
try {
|
||||
// Le service gère déjà le rafraîchissement automatique
|
||||
// On peut ajouter ici une logique spécifique si nécessaire
|
||||
// Simulation du rafraîchissement (à remplacer par l'API réelle)
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
final newToken = 'refreshed_token_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
emit(currentState.copyWith(accessToken: newToken));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthState.error('Erreur lors du rafraîchissement: $e'));
|
||||
emit(AuthError(message: 'Erreur de rafraîchissement: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion de l'expiration de session
|
||||
Future<void> _onSessionExpired(
|
||||
AuthSessionExpired event,
|
||||
/// Vérifie l'état d'authentification Keycloak
|
||||
Future<void> _onStatusChecked(
|
||||
AuthStatusChecked event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthState.expired());
|
||||
|
||||
// Optionnel: essayer un rafraîchissement automatique
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
await _authService.logout();
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de déconnexion lors de l'expiration
|
||||
debugPrint('🔍 Vérification état authentification Keycloak...');
|
||||
|
||||
// Vérifier si l'utilisateur est authentifié avec Keycloak
|
||||
final bool isAuthenticated = await KeycloakAuthService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
debugPrint('❌ Utilisateur non authentifié');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
final User? user = await KeycloakAuthService.getCurrentUser();
|
||||
|
||||
if (user == null) {
|
||||
debugPrint('❌ Impossible de récupérer l\'utilisateur');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculer les permissions effectives
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
|
||||
// Récupérer le token d'accès
|
||||
final String? accessToken = await KeycloakAuthService.getAccessToken();
|
||||
|
||||
debugPrint('✅ Utilisateur authentifié: ${user.fullName}');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: accessToken ?? '',
|
||||
));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur vérification authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de vérification: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérification du statut d'authentification
|
||||
Future<void> _onStatusCheckRequested(
|
||||
AuthStatusCheckRequested event,
|
||||
/// Met à jour le profil utilisateur
|
||||
Future<void> _onUserProfileUpdated(
|
||||
AuthUserProfileUpdated event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
// Utiliser l'état actuel du service
|
||||
final currentServiceState = _authService.currentState;
|
||||
if (currentServiceState != state) {
|
||||
emit(currentServiceState);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoyage des erreurs
|
||||
void _onErrorCleared(
|
||||
AuthErrorCleared event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
if (state.errorMessage != null) {
|
||||
emit(state.copyWith(errorMessage: null));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mise à jour depuis le service d'authentification
|
||||
void _onStateChanged(
|
||||
AuthStateChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
final newState = event.authState as AuthState;
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
// Émettre le nouvel état seulement s'il a changé
|
||||
if (newState != state) {
|
||||
emit(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est connecté
|
||||
bool get isAuthenticated => state.isAuthenticated;
|
||||
|
||||
/// Récupère l'utilisateur actuel
|
||||
get currentUser => state.user;
|
||||
|
||||
/// Vérifie si l'utilisateur a un rôle spécifique
|
||||
bool hasRole(String role) {
|
||||
return _authService.hasRole(role);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a un des rôles spécifiés
|
||||
bool hasAnyRole(List<String> roles) {
|
||||
return _authService.hasAnyRole(roles);
|
||||
}
|
||||
|
||||
/// Vérifie si la session expire bientôt
|
||||
bool get isSessionExpiringSoon => state.isExpiringSoon;
|
||||
|
||||
/// Récupère le message d'erreur formaté
|
||||
String? get errorMessage {
|
||||
final error = state.errorMessage;
|
||||
if (error == null) return null;
|
||||
|
||||
// Formatage des messages d'erreur pour l'utilisateur
|
||||
if (error.contains('network') || error.contains('connexion')) {
|
||||
return 'Problème de connexion. Vérifiez votre réseau.';
|
||||
}
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
if (error.contains('401') || error.contains('Identifiants')) {
|
||||
return 'Email ou mot de passe incorrect.';
|
||||
try {
|
||||
// Recalculer les permissions si nécessaire
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
event.updatedUser,
|
||||
organizationId: currentState.currentOrganizationId,
|
||||
);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
user: event.updatedUser,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de mise à jour: $e'));
|
||||
}
|
||||
|
||||
if (error.contains('403')) {
|
||||
return 'Accès non autorisé.';
|
||||
}
|
||||
|
||||
if (error.contains('timeout')) {
|
||||
return 'Délai d\'attente dépassé. Réessayez.';
|
||||
}
|
||||
|
||||
if (error.contains('server') || error.contains('500')) {
|
||||
return 'Erreur serveur temporaire. Réessayez plus tard.';
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_authStateSubscription.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/login_request.dart';
|
||||
|
||||
/// Événements d'authentification
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Initialiser l'authentification
|
||||
class AuthInitializeRequested extends AuthEvent {
|
||||
const AuthInitializeRequested();
|
||||
}
|
||||
|
||||
/// Demande de connexion
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
final LoginRequest loginRequest;
|
||||
|
||||
const AuthLoginRequested(this.loginRequest);
|
||||
|
||||
@override
|
||||
List<Object> get props => [loginRequest];
|
||||
}
|
||||
|
||||
/// Demande de déconnexion
|
||||
class AuthLogoutRequested extends AuthEvent {
|
||||
const AuthLogoutRequested();
|
||||
}
|
||||
|
||||
/// Demande de rafraîchissement de token
|
||||
class AuthTokenRefreshRequested extends AuthEvent {
|
||||
const AuthTokenRefreshRequested();
|
||||
}
|
||||
|
||||
/// Session expirée
|
||||
class AuthSessionExpired extends AuthEvent {
|
||||
const AuthSessionExpired();
|
||||
}
|
||||
|
||||
/// Vérification de l'état d'authentification
|
||||
class AuthStatusCheckRequested extends AuthEvent {
|
||||
const AuthStatusCheckRequested();
|
||||
}
|
||||
|
||||
/// Réinitialisation de l'erreur
|
||||
class AuthErrorCleared extends AuthEvent {
|
||||
const AuthErrorCleared();
|
||||
}
|
||||
|
||||
/// Changement d'état depuis le service
|
||||
class AuthStateChanged extends AuthEvent {
|
||||
final dynamic authState;
|
||||
|
||||
const AuthStateChanged(this.authState);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [authState];
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../services/temp_auth_service.dart';
|
||||
import 'auth_event.dart';
|
||||
|
||||
/// BLoC temporaire pour test sans injection de dépendances
|
||||
class TempAuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final TempAuthService _authService;
|
||||
late StreamSubscription<AuthState> _authStateSubscription;
|
||||
|
||||
TempAuthBloc(this._authService) : super(const AuthState.unknown()) {
|
||||
_authStateSubscription = _authService.authStateStream.listen(
|
||||
(authState) => add(AuthStateChanged(authState)),
|
||||
);
|
||||
|
||||
on<AuthInitializeRequested>(_onInitializeRequested);
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthErrorCleared>(_onErrorCleared);
|
||||
on<AuthStateChanged>(_onStateChanged);
|
||||
}
|
||||
|
||||
Future<void> _onInitializeRequested(
|
||||
AuthInitializeRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
await _authService.initialize();
|
||||
}
|
||||
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _authService.login(event.loginRequest);
|
||||
} catch (e) {
|
||||
emit(AuthState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
await _authService.logout();
|
||||
}
|
||||
|
||||
void _onErrorCleared(
|
||||
AuthErrorCleared event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
if (state.errorMessage != null) {
|
||||
emit(state.copyWith(errorMessage: null));
|
||||
}
|
||||
}
|
||||
|
||||
void _onStateChanged(
|
||||
AuthStateChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
final newState = event.authState as AuthState;
|
||||
if (newState != state) {
|
||||
emit(newState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_authStateSubscription.cancel();
|
||||
_authService.dispose();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'user_info.dart';
|
||||
|
||||
/// États d'authentification possibles
|
||||
enum AuthStatus {
|
||||
unknown, // État initial
|
||||
checking, // Vérification en cours
|
||||
authenticated,// Utilisateur connecté
|
||||
unauthenticated, // Utilisateur non connecté
|
||||
expired, // Session expirée
|
||||
error, // Erreur d'authentification
|
||||
}
|
||||
|
||||
/// État d'authentification de l'application
|
||||
class AuthState extends Equatable {
|
||||
final AuthStatus status;
|
||||
final UserInfo? user;
|
||||
final String? accessToken;
|
||||
final String? refreshToken;
|
||||
final DateTime? expiresAt;
|
||||
final String? errorMessage;
|
||||
final bool isLoading;
|
||||
|
||||
const AuthState({
|
||||
this.status = AuthStatus.unknown,
|
||||
this.user,
|
||||
this.accessToken,
|
||||
this.refreshToken,
|
||||
this.expiresAt,
|
||||
this.errorMessage,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
/// État initial inconnu
|
||||
const AuthState.unknown() : this(status: AuthStatus.unknown);
|
||||
|
||||
/// État de vérification
|
||||
const AuthState.checking() : this(
|
||||
status: AuthStatus.checking,
|
||||
isLoading: true,
|
||||
);
|
||||
|
||||
/// État authentifié
|
||||
const AuthState.authenticated({
|
||||
required UserInfo user,
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required DateTime expiresAt,
|
||||
}) : this(
|
||||
status: AuthStatus.authenticated,
|
||||
user: user,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: expiresAt,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// État non authentifié
|
||||
const AuthState.unauthenticated({String? errorMessage}) : this(
|
||||
status: AuthStatus.unauthenticated,
|
||||
errorMessage: errorMessage,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// État de session expirée
|
||||
const AuthState.expired() : this(
|
||||
status: AuthStatus.expired,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// État d'erreur
|
||||
const AuthState.error(String errorMessage) : this(
|
||||
status: AuthStatus.error,
|
||||
errorMessage: errorMessage,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// Vérifie si l'utilisateur est connecté
|
||||
bool get isAuthenticated => status == AuthStatus.authenticated;
|
||||
|
||||
/// Vérifie si l'authentification est en cours de vérification
|
||||
bool get isChecking => status == AuthStatus.checking;
|
||||
|
||||
/// Vérifie si la session est valide
|
||||
bool get isSessionValid {
|
||||
if (!isAuthenticated || expiresAt == null) return false;
|
||||
return DateTime.now().isBefore(expiresAt!);
|
||||
}
|
||||
|
||||
/// Vérifie si la session expire bientôt
|
||||
bool get isExpiringSoon {
|
||||
if (!isAuthenticated || expiresAt == null) return false;
|
||||
final threshold = DateTime.now().add(const Duration(minutes: 5));
|
||||
return expiresAt!.isBefore(threshold);
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
AuthState copyWith({
|
||||
AuthStatus? status,
|
||||
UserInfo? user,
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
DateTime? expiresAt,
|
||||
String? errorMessage,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
user: user ?? this.user,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une copie en effaçant les données sensibles
|
||||
AuthState clearSensitiveData() {
|
||||
return AuthState(
|
||||
status: status,
|
||||
user: user,
|
||||
errorMessage: errorMessage,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState(status: $status, user: ${user?.email}, isLoading: $isLoading, error: $errorMessage)';
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Modèle de requête de connexion
|
||||
class LoginRequest extends Equatable {
|
||||
final String email;
|
||||
final String password;
|
||||
final bool rememberMe;
|
||||
|
||||
const LoginRequest({
|
||||
required this.email,
|
||||
required this.password,
|
||||
this.rememberMe = false,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'rememberMe': rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
factory LoginRequest.fromJson(Map<String, dynamic> json) {
|
||||
return LoginRequest(
|
||||
email: json['email'] ?? '',
|
||||
password: json['password'] ?? '',
|
||||
rememberMe: json['rememberMe'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
LoginRequest copyWith({
|
||||
String? email,
|
||||
String? password,
|
||||
bool? rememberMe,
|
||||
}) {
|
||||
return LoginRequest(
|
||||
email: email ?? this.email,
|
||||
password: password ?? this.password,
|
||||
rememberMe: rememberMe ?? this.rememberMe,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [email, password, rememberMe];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LoginRequest(email: $email, rememberMe: $rememberMe)';
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'user_info.dart';
|
||||
|
||||
/// Modèle de réponse de connexion
|
||||
class LoginResponse extends Equatable {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String tokenType;
|
||||
final DateTime expiresAt;
|
||||
final DateTime refreshExpiresAt;
|
||||
final UserInfo user;
|
||||
|
||||
const LoginResponse({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.tokenType,
|
||||
required this.expiresAt,
|
||||
required this.refreshExpiresAt,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
/// Vérifie si le token d'accès est expiré
|
||||
bool get isAccessTokenExpired {
|
||||
return DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
/// Vérifie si le refresh token est expiré
|
||||
bool get isRefreshTokenExpired {
|
||||
return DateTime.now().isAfter(refreshExpiresAt);
|
||||
}
|
||||
|
||||
/// Vérifie si le token expire dans les prochaines minutes
|
||||
bool isExpiringSoon({int minutes = 5}) {
|
||||
final threshold = DateTime.now().add(Duration(minutes: minutes));
|
||||
return expiresAt.isBefore(threshold);
|
||||
}
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
return LoginResponse(
|
||||
accessToken: json['accessToken'] ?? '',
|
||||
refreshToken: json['refreshToken'] ?? '',
|
||||
tokenType: json['tokenType'] ?? 'Bearer',
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.parse(json['expiresAt'])
|
||||
: DateTime.now().add(const Duration(minutes: 15)),
|
||||
refreshExpiresAt: json['refreshExpiresAt'] != null
|
||||
? DateTime.parse(json['refreshExpiresAt'])
|
||||
: DateTime.now().add(const Duration(days: 7)),
|
||||
user: UserInfo.fromJson(json['user'] ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'accessToken': accessToken,
|
||||
'refreshToken': refreshToken,
|
||||
'tokenType': tokenType,
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'refreshExpiresAt': refreshExpiresAt.toIso8601String(),
|
||||
'user': user.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
LoginResponse copyWith({
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
String? tokenType,
|
||||
DateTime? expiresAt,
|
||||
DateTime? refreshExpiresAt,
|
||||
UserInfo? user,
|
||||
}) {
|
||||
return LoginResponse(
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
tokenType: tokenType ?? this.tokenType,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
refreshExpiresAt: refreshExpiresAt ?? this.refreshExpiresAt,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType,
|
||||
expiresAt,
|
||||
refreshExpiresAt,
|
||||
user,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LoginResponse(tokenType: $tokenType, user: ${user.email}, expiresAt: $expiresAt)';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Export all auth models
|
||||
export 'auth_state.dart';
|
||||
export 'login_request.dart';
|
||||
export 'login_response.dart';
|
||||
export 'user_info.dart';
|
||||
@@ -0,0 +1,212 @@
|
||||
/// Système de permissions granulaires ultra-sophistiqué
|
||||
/// Plus de 50 permissions atomiques avec héritage intelligent
|
||||
library permission_matrix;
|
||||
|
||||
/// Matrice de permissions atomiques pour contrôle granulaire
|
||||
///
|
||||
/// Chaque permission suit la convention : `domain.action.scope`
|
||||
/// Exemples : `members.edit.own`, `finances.view.all`, `system.admin.global`
|
||||
class PermissionMatrix {
|
||||
// === PERMISSIONS SYSTÈME ===
|
||||
static const String SYSTEM_ADMIN = 'system.admin.global';
|
||||
static const String SYSTEM_CONFIG = 'system.config.global';
|
||||
static const String SYSTEM_MONITORING = 'system.monitoring.view';
|
||||
static const String SYSTEM_BACKUP = 'system.backup.manage';
|
||||
static const String SYSTEM_SECURITY = 'system.security.manage';
|
||||
static const String SYSTEM_AUDIT = 'system.audit.view';
|
||||
static const String SYSTEM_LOGS = 'system.logs.view';
|
||||
static const String SYSTEM_MAINTENANCE = 'system.maintenance.execute';
|
||||
|
||||
// === PERMISSIONS ORGANISATION ===
|
||||
static const String ORG_CREATE = 'organization.create.global';
|
||||
static const String ORG_DELETE = 'organization.delete.own';
|
||||
static const String ORG_CONFIG = 'organization.config.own';
|
||||
static const String ORG_BRANDING = 'organization.branding.manage';
|
||||
static const String ORG_SETTINGS = 'organization.settings.manage';
|
||||
static const String ORG_PERMISSIONS = 'organization.permissions.manage';
|
||||
static const String ORG_WORKFLOWS = 'organization.workflows.manage';
|
||||
static const String ORG_INTEGRATIONS = 'organization.integrations.manage';
|
||||
|
||||
// === PERMISSIONS DASHBOARD ===
|
||||
static const String DASHBOARD_VIEW = 'dashboard.view.own';
|
||||
static const String DASHBOARD_ADMIN = 'dashboard.admin.view';
|
||||
static const String DASHBOARD_ANALYTICS = 'dashboard.analytics.view';
|
||||
static const String DASHBOARD_REPORTS = 'dashboard.reports.generate';
|
||||
static const String DASHBOARD_EXPORT = 'dashboard.export.data';
|
||||
static const String DASHBOARD_CUSTOMIZE = 'dashboard.customize.layout';
|
||||
|
||||
// === PERMISSIONS MEMBRES ===
|
||||
static const String MEMBERS_VIEW_ALL = 'members.view.all';
|
||||
static const String MEMBERS_VIEW_OWN = 'members.view.own';
|
||||
static const String MEMBERS_CREATE = 'members.create.organization';
|
||||
static const String MEMBERS_EDIT_ALL = 'members.edit.all';
|
||||
static const String MEMBERS_EDIT_OWN = 'members.edit.own';
|
||||
static const String MEMBERS_EDIT_BASIC = 'members.edit.basic';
|
||||
static const String MEMBERS_DELETE = 'members.delete.organization';
|
||||
static const String MEMBERS_DELETE_ALL = 'members.delete.all';
|
||||
static const String MEMBERS_APPROVE = 'members.approve.requests';
|
||||
static const String MEMBERS_SUSPEND = 'members.suspend.organization';
|
||||
static const String MEMBERS_EXPORT = 'members.export.data';
|
||||
static const String MEMBERS_IMPORT = 'members.import.data';
|
||||
static const String MEMBERS_COMMUNICATE = 'members.communicate.all';
|
||||
|
||||
// === PERMISSIONS FINANCES ===
|
||||
static const String FINANCES_VIEW_ALL = 'finances.view.all';
|
||||
static const String FINANCES_VIEW_OWN = 'finances.view.own';
|
||||
static const String FINANCES_EDIT_ALL = 'finances.edit.all';
|
||||
static const String FINANCES_MANAGE = 'finances.manage.organization';
|
||||
static const String FINANCES_APPROVE = 'finances.approve.transactions';
|
||||
static const String FINANCES_REPORTS = 'finances.reports.generate';
|
||||
static const String FINANCES_BUDGET = 'finances.budget.manage';
|
||||
static const String FINANCES_AUDIT = 'finances.audit.access';
|
||||
|
||||
// === PERMISSIONS ÉVÉNEMENTS ===
|
||||
static const String EVENTS_VIEW_ALL = 'events.view.all';
|
||||
static const String EVENTS_VIEW_PUBLIC = 'events.view.public';
|
||||
static const String EVENTS_CREATE = 'events.create.organization';
|
||||
static const String EVENTS_EDIT_ALL = 'events.edit.all';
|
||||
static const String EVENTS_EDIT_OWN = 'events.edit.own';
|
||||
static const String EVENTS_DELETE = 'events.delete.organization';
|
||||
static const String EVENTS_PARTICIPATE = 'events.participate.public';
|
||||
static const String EVENTS_MODERATE = 'events.moderate.organization';
|
||||
static const String EVENTS_ANALYTICS = 'events.analytics.view';
|
||||
|
||||
// === PERMISSIONS SOLIDARITÉ ===
|
||||
static const String SOLIDARITY_VIEW_ALL = 'solidarity.view.all';
|
||||
static const String SOLIDARITY_VIEW_OWN = 'solidarity.view.own';
|
||||
static const String SOLIDARITY_VIEW_PUBLIC = 'solidarity.view.public';
|
||||
static const String SOLIDARITY_CREATE = 'solidarity.create.request';
|
||||
static const String SOLIDARITY_EDIT_ALL = 'solidarity.edit.all';
|
||||
static const String SOLIDARITY_APPROVE = 'solidarity.approve.requests';
|
||||
static const String SOLIDARITY_PARTICIPATE = 'solidarity.participate.actions';
|
||||
static const String SOLIDARITY_MANAGE = 'solidarity.manage.organization';
|
||||
static const String SOLIDARITY_FUND = 'solidarity.fund.manage';
|
||||
|
||||
// === PERMISSIONS COMMUNICATION ===
|
||||
static const String COMM_SEND_ALL = 'communication.send.all';
|
||||
static const String COMM_SEND_MEMBERS = 'communication.send.members';
|
||||
static const String COMM_MODERATE = 'communication.moderate.organization';
|
||||
static const String COMM_BROADCAST = 'communication.broadcast.organization';
|
||||
static const String COMM_TEMPLATES = 'communication.templates.manage';
|
||||
|
||||
// === PERMISSIONS RAPPORTS ===
|
||||
static const String REPORTS_VIEW_ALL = 'reports.view.all';
|
||||
static const String REPORTS_GENERATE = 'reports.generate.organization';
|
||||
static const String REPORTS_EXPORT = 'reports.export.data';
|
||||
static const String REPORTS_SCHEDULE = 'reports.schedule.automated';
|
||||
|
||||
// === PERMISSIONS MODÉRATION ===
|
||||
static const String MODERATION_CONTENT = 'moderation.content.manage';
|
||||
static const String MODERATION_USERS = 'moderation.users.manage';
|
||||
static const String MODERATION_REPORTS = 'moderation.reports.handle';
|
||||
|
||||
/// Toutes les permissions disponibles dans le système
|
||||
static const List<String> ALL_PERMISSIONS = [
|
||||
// Système
|
||||
SYSTEM_ADMIN, SYSTEM_CONFIG, SYSTEM_MONITORING, SYSTEM_BACKUP,
|
||||
SYSTEM_SECURITY, SYSTEM_AUDIT, SYSTEM_LOGS, SYSTEM_MAINTENANCE,
|
||||
|
||||
// Organisation
|
||||
ORG_CREATE, ORG_DELETE, ORG_CONFIG, ORG_BRANDING, ORG_SETTINGS,
|
||||
ORG_PERMISSIONS, ORG_WORKFLOWS, ORG_INTEGRATIONS,
|
||||
|
||||
// Dashboard
|
||||
DASHBOARD_VIEW, DASHBOARD_ADMIN, DASHBOARD_ANALYTICS, DASHBOARD_REPORTS,
|
||||
DASHBOARD_EXPORT, DASHBOARD_CUSTOMIZE,
|
||||
|
||||
// Membres
|
||||
MEMBERS_VIEW_ALL, MEMBERS_VIEW_OWN, MEMBERS_CREATE, MEMBERS_EDIT_ALL,
|
||||
MEMBERS_EDIT_OWN, MEMBERS_DELETE, MEMBERS_APPROVE, MEMBERS_SUSPEND,
|
||||
MEMBERS_EXPORT, MEMBERS_IMPORT, MEMBERS_COMMUNICATE,
|
||||
|
||||
// Finances
|
||||
FINANCES_VIEW_ALL, FINANCES_VIEW_OWN, FINANCES_MANAGE, FINANCES_APPROVE,
|
||||
FINANCES_REPORTS, FINANCES_BUDGET, FINANCES_AUDIT,
|
||||
|
||||
// Événements
|
||||
EVENTS_VIEW_ALL, EVENTS_VIEW_PUBLIC, EVENTS_CREATE, EVENTS_EDIT_ALL,
|
||||
EVENTS_EDIT_OWN, EVENTS_DELETE, EVENTS_MODERATE, EVENTS_ANALYTICS,
|
||||
|
||||
// Solidarité
|
||||
SOLIDARITY_VIEW_ALL, SOLIDARITY_VIEW_OWN, SOLIDARITY_CREATE,
|
||||
SOLIDARITY_APPROVE, SOLIDARITY_MANAGE, SOLIDARITY_FUND,
|
||||
|
||||
// Communication
|
||||
COMM_SEND_ALL, COMM_SEND_MEMBERS, COMM_MODERATE, COMM_BROADCAST,
|
||||
COMM_TEMPLATES,
|
||||
|
||||
// Rapports
|
||||
REPORTS_VIEW_ALL, REPORTS_GENERATE, REPORTS_EXPORT, REPORTS_SCHEDULE,
|
||||
|
||||
// Modération
|
||||
MODERATION_CONTENT, MODERATION_USERS, MODERATION_REPORTS,
|
||||
];
|
||||
|
||||
/// Permissions publiques (accessibles sans authentification)
|
||||
static const List<String> PUBLIC_PERMISSIONS = [
|
||||
EVENTS_VIEW_PUBLIC,
|
||||
];
|
||||
|
||||
/// Vérifie si une permission est publique
|
||||
static bool isPublicPermission(String permission) {
|
||||
return PUBLIC_PERMISSIONS.contains(permission);
|
||||
}
|
||||
|
||||
/// Obtient le domaine d'une permission (partie avant le premier point)
|
||||
static String getDomain(String permission) {
|
||||
return permission.split('.').first;
|
||||
}
|
||||
|
||||
/// Obtient l'action d'une permission (partie du milieu)
|
||||
static String getAction(String permission) {
|
||||
final parts = permission.split('.');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
}
|
||||
|
||||
/// Obtient la portée d'une permission (partie après le dernier point)
|
||||
static String getScope(String permission) {
|
||||
return permission.split('.').last;
|
||||
}
|
||||
|
||||
/// Vérifie si une permission implique une autre (héritage)
|
||||
static bool implies(String higherPermission, String lowerPermission) {
|
||||
// Exemple : 'members.edit.all' implique 'members.view.all'
|
||||
final higherParts = higherPermission.split('.');
|
||||
final lowerParts = lowerPermission.split('.');
|
||||
|
||||
if (higherParts.length != 3 || lowerParts.length != 3) return false;
|
||||
|
||||
// Même domaine requis
|
||||
if (higherParts[0] != lowerParts[0]) return false;
|
||||
|
||||
// Vérification des implications d'actions
|
||||
return _actionImplies(higherParts[1], lowerParts[1]) &&
|
||||
_scopeImplies(higherParts[2], lowerParts[2]);
|
||||
}
|
||||
|
||||
/// Vérifie si une action implique une autre
|
||||
static bool _actionImplies(String higherAction, String lowerAction) {
|
||||
const actionHierarchy = {
|
||||
'admin': ['manage', 'edit', 'create', 'delete', 'view'],
|
||||
'manage': ['edit', 'create', 'delete', 'view'],
|
||||
'edit': ['view'],
|
||||
'create': ['view'],
|
||||
'delete': ['view'],
|
||||
};
|
||||
|
||||
return actionHierarchy[higherAction]?.contains(lowerAction) ??
|
||||
higherAction == lowerAction;
|
||||
}
|
||||
|
||||
/// Vérifie si une portée implique une autre
|
||||
static bool _scopeImplies(String higherScope, String lowerScope) {
|
||||
const scopeHierarchy = {
|
||||
'global': ['all', 'organization', 'own'],
|
||||
'all': ['organization', 'own'],
|
||||
'organization': ['own'],
|
||||
};
|
||||
|
||||
return scopeHierarchy[higherScope]?.contains(lowerScope) ??
|
||||
higherScope == lowerScope;
|
||||
}
|
||||
}
|
||||
360
unionflow-mobile-apps/lib/core/auth/models/user.dart
Normal file
360
unionflow-mobile-apps/lib/core/auth/models/user.dart
Normal file
@@ -0,0 +1,360 @@
|
||||
/// Modèles de données utilisateur avec contexte et permissions
|
||||
/// Support des relations multi-organisations et permissions contextuelles
|
||||
library user_models;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'user_role.dart';
|
||||
import 'permission_matrix.dart';
|
||||
|
||||
/// Modèle utilisateur principal avec contexte multi-organisations
|
||||
///
|
||||
/// Supporte les utilisateurs ayant des rôles différents dans plusieurs organisations
|
||||
/// avec des permissions contextuelles et des préférences personnalisées
|
||||
class User extends Equatable {
|
||||
/// Identifiant unique de l'utilisateur
|
||||
final String id;
|
||||
|
||||
/// Informations personnelles
|
||||
final String email;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? avatar;
|
||||
final String? phone;
|
||||
|
||||
/// Rôle principal de l'utilisateur (le plus élevé)
|
||||
final UserRole primaryRole;
|
||||
|
||||
/// Contextes organisationnels (rôles dans différentes organisations)
|
||||
final List<UserOrganizationContext> organizationContexts;
|
||||
|
||||
/// Permissions supplémentaires accordées spécifiquement
|
||||
final List<String> additionalPermissions;
|
||||
|
||||
/// Permissions révoquées spécifiquement
|
||||
final List<String> revokedPermissions;
|
||||
|
||||
/// Préférences utilisateur
|
||||
final UserPreferences preferences;
|
||||
|
||||
/// Métadonnées
|
||||
final DateTime createdAt;
|
||||
final DateTime lastLoginAt;
|
||||
final bool isActive;
|
||||
final bool isVerified;
|
||||
|
||||
/// Constructeur du modèle utilisateur
|
||||
const User({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.primaryRole,
|
||||
this.avatar,
|
||||
this.phone,
|
||||
this.organizationContexts = const [],
|
||||
this.additionalPermissions = const [],
|
||||
this.revokedPermissions = const [],
|
||||
this.preferences = const UserPreferences(),
|
||||
required this.createdAt,
|
||||
required this.lastLoginAt,
|
||||
this.isActive = true,
|
||||
this.isVerified = false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
/// Nom complet de l'utilisateur
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
/// Initiales de l'utilisateur
|
||||
String get initials => '${firstName[0]}${lastName[0]}'.toUpperCase();
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission dans le contexte actuel
|
||||
bool hasPermission(String permission, {String? organizationId}) {
|
||||
// Vérification des permissions révoquées
|
||||
if (revokedPermissions.contains(permission)) return false;
|
||||
|
||||
// Vérification des permissions additionnelles
|
||||
if (additionalPermissions.contains(permission)) return true;
|
||||
|
||||
// Vérification du rôle principal
|
||||
if (primaryRole.hasPermission(permission)) return true;
|
||||
|
||||
// Vérification dans le contexte organisationnel spécifique
|
||||
if (organizationId != null) {
|
||||
final context = getOrganizationContext(organizationId);
|
||||
if (context?.role.hasPermission(permission) == true) return true;
|
||||
}
|
||||
|
||||
// Vérification dans tous les contextes organisationnels
|
||||
return organizationContexts.any((context) =>
|
||||
context.role.hasPermission(permission));
|
||||
}
|
||||
|
||||
/// Obtient le contexte organisationnel pour une organisation
|
||||
UserOrganizationContext? getOrganizationContext(String organizationId) {
|
||||
try {
|
||||
return organizationContexts.firstWhere(
|
||||
(context) => context.organizationId == organizationId,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le rôle dans une organisation spécifique
|
||||
UserRole getRoleInOrganization(String organizationId) {
|
||||
final context = getOrganizationContext(organizationId);
|
||||
return context?.role ?? primaryRole;
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est membre d'une organisation
|
||||
bool isMemberOfOrganization(String organizationId) {
|
||||
return organizationContexts.any(
|
||||
(context) => context.organizationId == organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient toutes les permissions effectives de l'utilisateur
|
||||
List<String> getEffectivePermissions({String? organizationId}) {
|
||||
final permissions = <String>{};
|
||||
|
||||
// Permissions du rôle principal
|
||||
permissions.addAll(primaryRole.getEffectivePermissions());
|
||||
|
||||
// Permissions des contextes organisationnels
|
||||
if (organizationId != null) {
|
||||
final context = getOrganizationContext(organizationId);
|
||||
if (context != null) {
|
||||
permissions.addAll(context.role.getEffectivePermissions());
|
||||
}
|
||||
} else {
|
||||
for (final context in organizationContexts) {
|
||||
permissions.addAll(context.role.getEffectivePermissions());
|
||||
}
|
||||
}
|
||||
|
||||
// Permissions additionnelles
|
||||
permissions.addAll(additionalPermissions);
|
||||
|
||||
// Retirer les permissions révoquées
|
||||
permissions.removeAll(revokedPermissions);
|
||||
|
||||
return permissions.toList()..sort();
|
||||
}
|
||||
|
||||
/// Crée une copie de l'utilisateur avec des modifications
|
||||
User copyWith({
|
||||
String? email,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? avatar,
|
||||
String? phone,
|
||||
UserRole? primaryRole,
|
||||
List<UserOrganizationContext>? organizationContexts,
|
||||
List<String>? additionalPermissions,
|
||||
List<String>? revokedPermissions,
|
||||
UserPreferences? preferences,
|
||||
DateTime? lastLoginAt,
|
||||
bool? isActive,
|
||||
bool? isVerified,
|
||||
}) {
|
||||
return User(
|
||||
id: id,
|
||||
email: email ?? this.email,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
avatar: avatar ?? this.avatar,
|
||||
phone: phone ?? this.phone,
|
||||
primaryRole: primaryRole ?? this.primaryRole,
|
||||
organizationContexts: organizationContexts ?? this.organizationContexts,
|
||||
additionalPermissions: additionalPermissions ?? this.additionalPermissions,
|
||||
revokedPermissions: revokedPermissions ?? this.revokedPermissions,
|
||||
preferences: preferences ?? this.preferences,
|
||||
createdAt: createdAt,
|
||||
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isVerified: isVerified ?? this.isVerified,
|
||||
);
|
||||
}
|
||||
|
||||
/// Conversion vers Map pour sérialisation
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'avatar': avatar,
|
||||
'phone': phone,
|
||||
'primaryRole': primaryRole.name,
|
||||
'organizationContexts': organizationContexts.map((c) => c.toJson()).toList(),
|
||||
'additionalPermissions': additionalPermissions,
|
||||
'revokedPermissions': revokedPermissions,
|
||||
'preferences': preferences.toJson(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'lastLoginAt': lastLoginAt.toIso8601String(),
|
||||
'isActive': isActive,
|
||||
'isVerified': isVerified,
|
||||
};
|
||||
}
|
||||
|
||||
/// Création depuis Map pour désérialisation
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'],
|
||||
email: json['email'],
|
||||
firstName: json['firstName'],
|
||||
lastName: json['lastName'],
|
||||
avatar: json['avatar'],
|
||||
phone: json['phone'],
|
||||
primaryRole: UserRole.fromString(json['primaryRole']) ?? UserRole.visitor,
|
||||
organizationContexts: (json['organizationContexts'] as List?)
|
||||
?.map((c) => UserOrganizationContext.fromJson(c))
|
||||
.toList() ?? [],
|
||||
additionalPermissions: List<String>.from(json['additionalPermissions'] ?? []),
|
||||
revokedPermissions: List<String>.from(json['revokedPermissions'] ?? []),
|
||||
preferences: UserPreferences.fromJson(json['preferences'] ?? {}),
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
lastLoginAt: DateTime.parse(json['lastLoginAt']),
|
||||
isActive: json['isActive'] ?? true,
|
||||
isVerified: json['isVerified'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id, email, firstName, lastName, avatar, phone, primaryRole,
|
||||
organizationContexts, additionalPermissions, revokedPermissions,
|
||||
preferences, createdAt, lastLoginAt, isActive, isVerified,
|
||||
];
|
||||
}
|
||||
|
||||
/// Contexte organisationnel d'un utilisateur
|
||||
///
|
||||
/// Définit le rôle et les permissions spécifiques dans une organisation
|
||||
class UserOrganizationContext extends Equatable {
|
||||
/// Identifiant de l'organisation
|
||||
final String organizationId;
|
||||
|
||||
/// Nom de l'organisation
|
||||
final String organizationName;
|
||||
|
||||
/// Rôle de l'utilisateur dans cette organisation
|
||||
final UserRole role;
|
||||
|
||||
/// Permissions spécifiques dans cette organisation
|
||||
final List<String> specificPermissions;
|
||||
|
||||
/// Date d'adhésion à l'organisation
|
||||
final DateTime joinedAt;
|
||||
|
||||
/// Statut dans l'organisation
|
||||
final bool isActive;
|
||||
|
||||
/// Constructeur du contexte organisationnel
|
||||
const UserOrganizationContext({
|
||||
required this.organizationId,
|
||||
required this.organizationName,
|
||||
required this.role,
|
||||
this.specificPermissions = const [],
|
||||
required this.joinedAt,
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
/// Conversion vers Map
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'organizationId': organizationId,
|
||||
'organizationName': organizationName,
|
||||
'role': role.name,
|
||||
'specificPermissions': specificPermissions,
|
||||
'joinedAt': joinedAt.toIso8601String(),
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/// Création depuis Map
|
||||
factory UserOrganizationContext.fromJson(Map<String, dynamic> json) {
|
||||
return UserOrganizationContext(
|
||||
organizationId: json['organizationId'],
|
||||
organizationName: json['organizationName'],
|
||||
role: UserRole.fromString(json['role']) ?? UserRole.visitor,
|
||||
specificPermissions: List<String>.from(json['specificPermissions'] ?? []),
|
||||
joinedAt: DateTime.parse(json['joinedAt']),
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
organizationId, organizationName, role, specificPermissions, joinedAt, isActive,
|
||||
];
|
||||
}
|
||||
|
||||
/// Préférences utilisateur personnalisables
|
||||
class UserPreferences extends Equatable {
|
||||
/// Langue préférée
|
||||
final String language;
|
||||
|
||||
/// Thème préféré
|
||||
final String theme;
|
||||
|
||||
/// Notifications activées
|
||||
final bool notificationsEnabled;
|
||||
|
||||
/// Notifications par email
|
||||
final bool emailNotifications;
|
||||
|
||||
/// Notifications push
|
||||
final bool pushNotifications;
|
||||
|
||||
/// Layout du dashboard préféré
|
||||
final String dashboardLayout;
|
||||
|
||||
/// Timezone
|
||||
final String timezone;
|
||||
|
||||
/// Constructeur des préférences
|
||||
const UserPreferences({
|
||||
this.language = 'fr',
|
||||
this.theme = 'system',
|
||||
this.notificationsEnabled = true,
|
||||
this.emailNotifications = true,
|
||||
this.pushNotifications = true,
|
||||
this.dashboardLayout = 'default',
|
||||
this.timezone = 'Europe/Paris',
|
||||
});
|
||||
|
||||
/// Conversion vers Map
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'language': language,
|
||||
'theme': theme,
|
||||
'notificationsEnabled': notificationsEnabled,
|
||||
'emailNotifications': emailNotifications,
|
||||
'pushNotifications': pushNotifications,
|
||||
'dashboardLayout': dashboardLayout,
|
||||
'timezone': timezone,
|
||||
};
|
||||
}
|
||||
|
||||
/// Création depuis Map
|
||||
factory UserPreferences.fromJson(Map<String, dynamic> json) {
|
||||
return UserPreferences(
|
||||
language: json['language'] ?? 'fr',
|
||||
theme: json['theme'] ?? 'system',
|
||||
notificationsEnabled: json['notificationsEnabled'] ?? true,
|
||||
emailNotifications: json['emailNotifications'] ?? true,
|
||||
pushNotifications: json['pushNotifications'] ?? true,
|
||||
dashboardLayout: json['dashboardLayout'] ?? 'default',
|
||||
timezone: json['timezone'] ?? 'Europe/Paris',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
language, theme, notificationsEnabled, emailNotifications,
|
||||
pushNotifications, dashboardLayout, timezone,
|
||||
];
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Modèle des informations utilisateur
|
||||
class UserInfo extends Equatable {
|
||||
final String id;
|
||||
final String email;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String role;
|
||||
final List<String>? roles;
|
||||
final String? profilePicture;
|
||||
final bool isActive;
|
||||
|
||||
const UserInfo({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.role,
|
||||
this.roles,
|
||||
this.profilePicture,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
String get initials {
|
||||
final f = firstName.isNotEmpty ? firstName[0] : '';
|
||||
final l = lastName.isNotEmpty ? lastName[0] : '';
|
||||
return '$f$l'.toUpperCase();
|
||||
}
|
||||
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
id: json['id'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'] ?? '',
|
||||
role: json['role'] ?? 'membre',
|
||||
roles: json['roles'] != null ? List<String>.from(json['roles']) : null,
|
||||
profilePicture: json['profilePicture'],
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'role': role,
|
||||
'roles': roles,
|
||||
'profilePicture': profilePicture,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
UserInfo copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? role,
|
||||
List<String>? roles,
|
||||
String? profilePicture,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return UserInfo(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
role: role ?? this.role,
|
||||
roles: roles ?? this.roles,
|
||||
profilePicture: profilePicture ?? this.profilePicture,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
role,
|
||||
roles,
|
||||
profilePicture,
|
||||
isActive,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserInfo(id: $id, email: $email, fullName: $fullName, role: $role, isActive: $isActive)';
|
||||
}
|
||||
}
|
||||
319
unionflow-mobile-apps/lib/core/auth/models/user_role.dart
Normal file
319
unionflow-mobile-apps/lib/core/auth/models/user_role.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
/// Système de rôles utilisateurs avec hiérarchie intelligente
|
||||
/// 6 niveaux de rôles avec permissions héritées et contextuelles
|
||||
library user_role;
|
||||
|
||||
import 'permission_matrix.dart';
|
||||
|
||||
/// Énumération des rôles utilisateurs avec hiérarchie et permissions
|
||||
///
|
||||
/// Chaque rôle a un niveau numérique pour faciliter les comparaisons
|
||||
/// et une liste de permissions spécifiques avec héritage intelligent
|
||||
enum UserRole {
|
||||
/// Super Administrateur - Niveau système (100)
|
||||
/// Accès complet à toutes les fonctionnalités multi-organisations
|
||||
superAdmin(
|
||||
level: 100,
|
||||
displayName: 'Super Administrateur',
|
||||
description: 'Accès complet système et multi-organisations',
|
||||
color: 0xFF6C5CE7, // Violet sophistiqué
|
||||
permissions: _superAdminPermissions,
|
||||
),
|
||||
|
||||
/// Administrateur d'Organisation - Niveau organisation (80)
|
||||
/// Gestion complète de son organisation uniquement
|
||||
orgAdmin(
|
||||
level: 80,
|
||||
displayName: 'Administrateur',
|
||||
description: 'Gestion complète de l\'organisation',
|
||||
color: 0xFF0984E3, // Bleu corporate
|
||||
permissions: _orgAdminPermissions,
|
||||
),
|
||||
|
||||
/// Modérateur/Gestionnaire - Niveau intermédiaire (60)
|
||||
/// Gestion partielle selon permissions accordées
|
||||
moderator(
|
||||
level: 60,
|
||||
displayName: 'Modérateur',
|
||||
description: 'Gestion partielle et modération',
|
||||
color: 0xFFE17055, // Orange focus
|
||||
permissions: _moderatorPermissions,
|
||||
),
|
||||
|
||||
/// Membre Actif - Niveau utilisateur (40)
|
||||
/// Accès aux fonctionnalités membres avec participation active
|
||||
activeMember(
|
||||
level: 40,
|
||||
displayName: 'Membre Actif',
|
||||
description: 'Participation active aux activités',
|
||||
color: 0xFF00B894, // Vert communauté
|
||||
permissions: _activeMemberPermissions,
|
||||
),
|
||||
|
||||
/// Membre Simple - Niveau basique (20)
|
||||
/// Accès limité aux informations personnelles
|
||||
simpleMember(
|
||||
level: 20,
|
||||
displayName: 'Membre',
|
||||
description: 'Accès aux informations de base',
|
||||
color: 0xFF00CEC9, // Teal simple
|
||||
permissions: _simpleMemberPermissions,
|
||||
),
|
||||
|
||||
/// Visiteur/Invité - Niveau public (0)
|
||||
/// Accès aux informations publiques uniquement
|
||||
visitor(
|
||||
level: 0,
|
||||
displayName: 'Visiteur',
|
||||
description: 'Accès aux informations publiques',
|
||||
color: 0xFF6C5CE7, // Indigo accueillant
|
||||
permissions: _visitorPermissions,
|
||||
);
|
||||
|
||||
/// Constructeur du rôle avec toutes ses propriétés
|
||||
const UserRole({
|
||||
required this.level,
|
||||
required this.displayName,
|
||||
required this.description,
|
||||
required this.color,
|
||||
required this.permissions,
|
||||
});
|
||||
|
||||
/// Niveau numérique du rôle (0-100)
|
||||
final int level;
|
||||
|
||||
/// Nom d'affichage du rôle
|
||||
final String displayName;
|
||||
|
||||
/// Description détaillée du rôle
|
||||
final String description;
|
||||
|
||||
/// Couleur thématique du rôle (format 0xFFRRGGBB)
|
||||
final int color;
|
||||
|
||||
/// Liste des permissions spécifiques au rôle
|
||||
final List<String> permissions;
|
||||
|
||||
/// Vérifie si ce rôle a un niveau supérieur ou égal à un autre
|
||||
bool hasLevelOrAbove(UserRole other) => level >= other.level;
|
||||
|
||||
/// Vérifie si ce rôle a un niveau strictement supérieur à un autre
|
||||
bool hasLevelAbove(UserRole other) => level > other.level;
|
||||
|
||||
/// Vérifie si ce rôle possède une permission spécifique
|
||||
bool hasPermission(String permission) {
|
||||
// Vérification directe
|
||||
if (permissions.contains(permission)) return true;
|
||||
|
||||
// Vérification par héritage (permissions impliquées)
|
||||
return permissions.any((p) => PermissionMatrix.implies(p, permission));
|
||||
}
|
||||
|
||||
/// Obtient toutes les permissions effectives (directes + héritées)
|
||||
List<String> getEffectivePermissions() {
|
||||
final effective = <String>{};
|
||||
|
||||
// Ajouter les permissions directes
|
||||
effective.addAll(permissions);
|
||||
|
||||
// Ajouter les permissions impliquées
|
||||
for (final permission in permissions) {
|
||||
for (final allPermission in PermissionMatrix.ALL_PERMISSIONS) {
|
||||
if (PermissionMatrix.implies(permission, allPermission)) {
|
||||
effective.add(allPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effective.toList()..sort();
|
||||
}
|
||||
|
||||
/// Vérifie si ce rôle peut effectuer une action sur un domaine
|
||||
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
|
||||
final permission = '$domain.$action.$scope';
|
||||
return hasPermission(permission);
|
||||
}
|
||||
|
||||
/// Obtient le rôle à partir de son nom
|
||||
static UserRole? fromString(String roleName) {
|
||||
return UserRole.values.firstWhere(
|
||||
(role) => role.name == roleName,
|
||||
orElse: () => UserRole.visitor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient tous les rôles avec un niveau inférieur ou égal
|
||||
List<UserRole> getSubordinateRoles() {
|
||||
return UserRole.values.where((role) => role.level < level).toList();
|
||||
}
|
||||
|
||||
/// Obtient tous les rôles avec un niveau supérieur ou égal
|
||||
List<UserRole> getSuperiorRoles() {
|
||||
return UserRole.values.where((role) => role.level >= level).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// === DÉFINITIONS DES PERMISSIONS PAR RÔLE ===
|
||||
|
||||
/// Permissions du Super Administrateur (accès complet)
|
||||
const List<String> _superAdminPermissions = [
|
||||
// Toutes les permissions système
|
||||
PermissionMatrix.SYSTEM_ADMIN,
|
||||
PermissionMatrix.SYSTEM_CONFIG,
|
||||
PermissionMatrix.SYSTEM_MONITORING,
|
||||
PermissionMatrix.SYSTEM_BACKUP,
|
||||
PermissionMatrix.SYSTEM_SECURITY,
|
||||
PermissionMatrix.SYSTEM_AUDIT,
|
||||
PermissionMatrix.SYSTEM_LOGS,
|
||||
PermissionMatrix.SYSTEM_MAINTENANCE,
|
||||
|
||||
// Gestion globale des organisations
|
||||
PermissionMatrix.ORG_CREATE,
|
||||
PermissionMatrix.ORG_DELETE,
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
|
||||
// Accès complet aux dashboards
|
||||
PermissionMatrix.DASHBOARD_ADMIN,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
PermissionMatrix.DASHBOARD_REPORTS,
|
||||
PermissionMatrix.DASHBOARD_EXPORT,
|
||||
|
||||
// Gestion complète des membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_DELETE,
|
||||
PermissionMatrix.MEMBERS_EXPORT,
|
||||
PermissionMatrix.MEMBERS_IMPORT,
|
||||
|
||||
// Accès complet aux finances
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_MANAGE,
|
||||
PermissionMatrix.FINANCES_AUDIT,
|
||||
|
||||
// Tous les rapports
|
||||
PermissionMatrix.REPORTS_VIEW_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.REPORTS_EXPORT,
|
||||
PermissionMatrix.REPORTS_SCHEDULE,
|
||||
];
|
||||
|
||||
/// Permissions de l'Administrateur d'Organisation
|
||||
const List<String> _orgAdminPermissions = [
|
||||
// Configuration organisation
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
PermissionMatrix.ORG_BRANDING,
|
||||
PermissionMatrix.ORG_SETTINGS,
|
||||
PermissionMatrix.ORG_PERMISSIONS,
|
||||
PermissionMatrix.ORG_WORKFLOWS,
|
||||
|
||||
// Dashboard organisation
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
PermissionMatrix.DASHBOARD_REPORTS,
|
||||
PermissionMatrix.DASHBOARD_CUSTOMIZE,
|
||||
|
||||
// Gestion des membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_CREATE,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_APPROVE,
|
||||
PermissionMatrix.MEMBERS_SUSPEND,
|
||||
PermissionMatrix.MEMBERS_COMMUNICATE,
|
||||
|
||||
// Gestion financière
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_MANAGE,
|
||||
PermissionMatrix.FINANCES_REPORTS,
|
||||
PermissionMatrix.FINANCES_BUDGET,
|
||||
|
||||
// Gestion des événements
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_CREATE,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_DELETE,
|
||||
PermissionMatrix.EVENTS_ANALYTICS,
|
||||
|
||||
// Gestion de la solidarité
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_APPROVE,
|
||||
PermissionMatrix.SOLIDARITY_MANAGE,
|
||||
PermissionMatrix.SOLIDARITY_FUND,
|
||||
|
||||
// Communication
|
||||
PermissionMatrix.COMM_SEND_ALL,
|
||||
PermissionMatrix.COMM_BROADCAST,
|
||||
PermissionMatrix.COMM_TEMPLATES,
|
||||
|
||||
// Rapports organisation
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.REPORTS_EXPORT,
|
||||
];
|
||||
|
||||
/// Permissions du Modérateur
|
||||
const List<String> _moderatorPermissions = [
|
||||
// Dashboard limité
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
|
||||
// Modération des membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_APPROVE,
|
||||
PermissionMatrix.MODERATION_USERS,
|
||||
|
||||
// Modération du contenu
|
||||
PermissionMatrix.MODERATION_CONTENT,
|
||||
PermissionMatrix.MODERATION_REPORTS,
|
||||
|
||||
// Événements limités
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_MODERATE,
|
||||
|
||||
// Communication modérée
|
||||
PermissionMatrix.COMM_MODERATE,
|
||||
PermissionMatrix.COMM_SEND_MEMBERS,
|
||||
];
|
||||
|
||||
/// Permissions du Membre Actif
|
||||
const List<String> _activeMemberPermissions = [
|
||||
// Dashboard personnel
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
|
||||
// Profil personnel
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
PermissionMatrix.MEMBERS_EDIT_OWN,
|
||||
|
||||
// Finances personnelles
|
||||
PermissionMatrix.FINANCES_VIEW_OWN,
|
||||
|
||||
// Événements
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_CREATE,
|
||||
PermissionMatrix.EVENTS_EDIT_OWN,
|
||||
|
||||
// Solidarité
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_CREATE,
|
||||
];
|
||||
|
||||
/// Permissions du Membre Simple
|
||||
const List<String> _simpleMemberPermissions = [
|
||||
// Dashboard basique
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
|
||||
// Profil personnel uniquement
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
PermissionMatrix.MEMBERS_EDIT_OWN,
|
||||
|
||||
// Finances personnelles
|
||||
PermissionMatrix.FINANCES_VIEW_OWN,
|
||||
|
||||
// Événements publics
|
||||
PermissionMatrix.EVENTS_VIEW_PUBLIC,
|
||||
|
||||
// Solidarité consultation
|
||||
PermissionMatrix.SOLIDARITY_VIEW_OWN,
|
||||
];
|
||||
|
||||
/// Permissions du Visiteur
|
||||
const List<String> _visitorPermissions = [
|
||||
// Événements publics uniquement
|
||||
PermissionMatrix.EVENTS_VIEW_PUBLIC,
|
||||
];
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../features/auth/presentation/pages/keycloak_login_page.dart';
|
||||
import '../../../features/navigation/presentation/pages/main_navigation.dart';
|
||||
import '../services/keycloak_webview_auth_service.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../../di/injection.dart';
|
||||
|
||||
/// Wrapper qui gère l'authentification et le routage
|
||||
class AuthWrapper extends StatefulWidget {
|
||||
const AuthWrapper({super.key});
|
||||
|
||||
@override
|
||||
State<AuthWrapper> createState() => _AuthWrapperState();
|
||||
}
|
||||
|
||||
class _AuthWrapperState extends State<AuthWrapper> {
|
||||
late KeycloakWebViewAuthService _authService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_authService = getIt<KeycloakWebViewAuthService>();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<AuthState>(
|
||||
stream: _authService.authStateStream,
|
||||
initialData: _authService.currentState,
|
||||
builder: (context, snapshot) {
|
||||
final authState = snapshot.data ?? const AuthState.unknown();
|
||||
|
||||
// Affichage de l'écran de chargement pendant la vérification
|
||||
if (authState.isChecking) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Vérification de l\'authentification...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si l'utilisateur est authentifié, afficher l'application principale
|
||||
if (authState.isAuthenticated) {
|
||||
return const MainNavigation();
|
||||
}
|
||||
|
||||
// Sinon, afficher la page de connexion
|
||||
return const KeycloakLoginPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)' : ''}';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import '../models/login_response.dart';
|
||||
import '../models/user_info.dart';
|
||||
|
||||
/// Service de stockage en mémoire des tokens (temporaire pour contourner Java 21)
|
||||
class MemoryTokenStorage {
|
||||
static final MemoryTokenStorage _instance = MemoryTokenStorage._internal();
|
||||
factory MemoryTokenStorage() => _instance;
|
||||
MemoryTokenStorage._internal();
|
||||
|
||||
// Stockage en mémoire
|
||||
final Map<String, String> _storage = {};
|
||||
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _userInfoKey = 'user_info';
|
||||
static const String _expiresAtKey = 'expires_at';
|
||||
static const String _refreshExpiresAtKey = 'refresh_expires_at';
|
||||
|
||||
/// Sauvegarde les données d'authentification
|
||||
Future<void> saveAuthData(LoginResponse loginResponse) async {
|
||||
try {
|
||||
_storage[_accessTokenKey] = loginResponse.accessToken;
|
||||
_storage[_refreshTokenKey] = loginResponse.refreshToken;
|
||||
_storage[_userInfoKey] = jsonEncode(loginResponse.user.toJson());
|
||||
_storage[_expiresAtKey] = loginResponse.expiresAt.toIso8601String();
|
||||
_storage[_refreshExpiresAtKey] = loginResponse.refreshExpiresAt.toIso8601String();
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le token d'accès
|
||||
Future<String?> getAccessToken() async {
|
||||
return _storage[_accessTokenKey];
|
||||
}
|
||||
|
||||
/// Récupère le refresh token
|
||||
Future<String?> getRefreshToken() async {
|
||||
return _storage[_refreshTokenKey];
|
||||
}
|
||||
|
||||
/// Récupère les informations utilisateur
|
||||
Future<UserInfo?> getUserInfo() async {
|
||||
try {
|
||||
final userJson = _storage[_userInfoKey];
|
||||
if (userJson == null) return null;
|
||||
|
||||
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
|
||||
return UserInfo.fromJson(userMap);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du token d'accès
|
||||
Future<DateTime?> getTokenExpirationDate() async {
|
||||
try {
|
||||
final expiresAtString = _storage[_expiresAtKey];
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du refresh token
|
||||
Future<DateTime?> getRefreshTokenExpirationDate() async {
|
||||
try {
|
||||
final expiresAtString = _storage[_refreshExpiresAtKey];
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est authentifié
|
||||
Future<bool> hasValidToken() async {
|
||||
final token = await getAccessToken();
|
||||
if (token == null) return false;
|
||||
|
||||
final expirationDate = await getTokenExpirationDate();
|
||||
if (expirationDate == null) return false;
|
||||
|
||||
return DateTime.now().isBefore(expirationDate);
|
||||
}
|
||||
|
||||
/// Efface toutes les données d'authentification
|
||||
Future<void> clearAll() async {
|
||||
_storage.clear();
|
||||
}
|
||||
|
||||
/// Met à jour uniquement les tokens
|
||||
Future<void> updateTokens({
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required DateTime expiresAt,
|
||||
required DateTime refreshExpiresAt,
|
||||
}) async {
|
||||
_storage[_accessTokenKey] = accessToken;
|
||||
_storage[_refreshTokenKey] = refreshToken;
|
||||
_storage[_expiresAtKey] = expiresAt.toIso8601String();
|
||||
_storage[_refreshExpiresAtKey] = refreshExpiresAt.toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception personnalisée pour les erreurs de stockage
|
||||
class StorageException implements Exception {
|
||||
final String message;
|
||||
StorageException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'StorageException: $message';
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/login_response.dart';
|
||||
import '../models/user_info.dart';
|
||||
|
||||
/// Service de stockage sécurisé des tokens d'authentification
|
||||
@singleton
|
||||
class SecureTokenStorage {
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _userInfoKey = 'user_info';
|
||||
static const String _expiresAtKey = 'expires_at';
|
||||
static const String _refreshExpiresAtKey = 'refresh_expires_at';
|
||||
static const String _biometricEnabledKey = 'biometric_enabled';
|
||||
|
||||
// Utilise SharedPreferences temporairement pour Android
|
||||
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
|
||||
|
||||
/// Sauvegarde les données d'authentification
|
||||
Future<void> saveAuthData(LoginResponse loginResponse) async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await Future.wait([
|
||||
prefs.setString(_accessTokenKey, loginResponse.accessToken),
|
||||
prefs.setString(_refreshTokenKey, loginResponse.refreshToken),
|
||||
prefs.setString(_userInfoKey, jsonEncode(loginResponse.user.toJson())),
|
||||
prefs.setString(_expiresAtKey, loginResponse.expiresAt.toIso8601String()),
|
||||
prefs.setString(_refreshExpiresAtKey, loginResponse.refreshExpiresAt.toIso8601String()),
|
||||
]);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le token d'accès
|
||||
Future<String?> getAccessToken() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getString(_accessTokenKey);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération du token d\'accès: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le refresh token
|
||||
Future<String?> getRefreshToken() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getString(_refreshTokenKey);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération du refresh token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les informations utilisateur
|
||||
Future<UserInfo?> getUserInfo() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final userJson = prefs.getString(_userInfoKey);
|
||||
if (userJson == null) return null;
|
||||
|
||||
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
|
||||
return UserInfo.fromJson(userMap);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du token d'accès
|
||||
Future<DateTime?> getTokenExpirationDate() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final expiresAtString = prefs.getString(_expiresAtKey);
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du refresh token
|
||||
Future<DateTime?> getRefreshTokenExpirationDate() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final expiresAtString = prefs.getString(_refreshExpiresAtKey);
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère toutes les données d'authentification
|
||||
Future<LoginResponse?> getAuthData() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
getAccessToken(),
|
||||
getRefreshToken(),
|
||||
getUserInfo(),
|
||||
getTokenExpirationDate(),
|
||||
getRefreshTokenExpirationDate(),
|
||||
]);
|
||||
|
||||
final accessToken = results[0] as String?;
|
||||
final refreshToken = results[1] as String?;
|
||||
final userInfo = results[2] as UserInfo?;
|
||||
final expiresAt = results[3] as DateTime?;
|
||||
final refreshExpiresAt = results[4] as DateTime?;
|
||||
|
||||
if (accessToken == null ||
|
||||
refreshToken == null ||
|
||||
userInfo == null ||
|
||||
expiresAt == null ||
|
||||
refreshExpiresAt == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LoginResponse(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: expiresAt,
|
||||
refreshExpiresAt: refreshExpiresAt,
|
||||
user: userInfo,
|
||||
);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour le token d'accès
|
||||
Future<void> updateAccessToken(String accessToken, DateTime expiresAt) async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await Future.wait([
|
||||
prefs.setString(_accessTokenKey, accessToken),
|
||||
prefs.setString(_expiresAtKey, expiresAt.toIso8601String()),
|
||||
]);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la mise à jour du token d\'accès: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si les données d'authentification existent
|
||||
Future<bool> hasAuthData() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final accessToken = prefs.getString(_accessTokenKey);
|
||||
final refreshToken = prefs.getString(_refreshTokenKey);
|
||||
return accessToken != null && refreshToken != null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si les tokens sont expirés
|
||||
Future<bool> areTokensExpired() async {
|
||||
try {
|
||||
final expiresAt = await getTokenExpirationDate();
|
||||
final refreshExpiresAt = await getRefreshTokenExpirationDate();
|
||||
|
||||
if (expiresAt == null || refreshExpiresAt == null) return true;
|
||||
|
||||
final now = DateTime.now();
|
||||
return refreshExpiresAt.isBefore(now);
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le token d'accès expire bientôt
|
||||
Future<bool> isAccessTokenExpiringSoon({int minutes = 5}) async {
|
||||
try {
|
||||
final expiresAt = await getTokenExpirationDate();
|
||||
if (expiresAt == null) return true;
|
||||
|
||||
final threshold = DateTime.now().add(Duration(minutes: minutes));
|
||||
return expiresAt.isBefore(threshold);
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Efface toutes les données d'authentification
|
||||
Future<void> clearAuthData() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await Future.wait([
|
||||
prefs.remove(_accessTokenKey),
|
||||
prefs.remove(_refreshTokenKey),
|
||||
prefs.remove(_userInfoKey),
|
||||
prefs.remove(_expiresAtKey),
|
||||
prefs.remove(_refreshExpiresAtKey),
|
||||
]);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de l\'effacement des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Active/désactive l'authentification biométrique
|
||||
Future<void> setBiometricEnabled(bool enabled) async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setBool(_biometricEnabledKey, enabled);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la configuration biométrique: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'authentification biométrique est activée
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getBool(_biometricEnabledKey) ?? false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Efface toutes les données stockées
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await prefs.clear();
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de l\'effacement de toutes les données: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le stockage sécurisé est disponible
|
||||
Future<bool> isAvailable() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
return prefs.containsKey('test');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception liée au stockage
|
||||
class StorageException implements Exception {
|
||||
final String message;
|
||||
|
||||
const StorageException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'StorageException: $message';
|
||||
}
|
||||
Reference in New Issue
Block a user