Files
unionflow-mobile-apps/lib/features/authentication/data/datasources/keycloak_auth_service.dart
dahoud 120434aba0 feat(features): refontes adhesions/admin/auth/backup/contributions/dashboard/epargne/events
- adhesions : bloc complet avec events/states/model, dialogs paiement/rejet
- admin : users bloc, user management list/detail pages
- authentication : bloc + keycloak auth service + webview
- backup : bloc complet, repository, models
- contributions : bloc + widgets + export
- dashboard : widgets connectés (activities, events, notifications, search)
  + charts + monitoring + shortcuts
- epargne : repository, transactions, dialogs
- events : bloc complet, pages (detail, connected, wrapper), models
2026-04-15 20:26:48 +00:00

310 lines
12 KiB
Dart

import 'package:dio/dio.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 'package:injectable/injectable.dart';
import '../models/user.dart';
import 'keycloak_role_mapper.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/utils/logger.dart';
/// Configuration Keycloak centralisée
class KeycloakConfig {
static String get baseUrl => AppConfig.keycloakBaseUrl;
static const String realm = 'unionflow';
static const String clientId = 'unionflow-mobile';
static const String scopes = 'openid profile email roles';
static String get tokenEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get logoutEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/logout';
}
/// Service d'Authentification Keycloak Épuré & DRY
@lazySingleton
class KeycloakAuthService {
final Dio _dio = Dio();
final FlutterAppAuth _appAuth = const FlutterAppAuth();
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
static const String _accessK = 'kc_access';
static const String _refreshK = 'kc_refresh';
static const String _idK = 'kc_id';
/// Login via Authorization Code Flow + PKCE (AppAuth)
Future<User?> loginWithAppAuth() async {
try {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
KeycloakConfig.clientId,
'dev.lions.unionflow-mobile://auth/callback',
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint: '${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth',
tokenEndpoint: KeycloakConfig.tokenEndpoint,
),
scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'],
additionalParameters: {'kc_locale': 'fr'},
allowInsecureConnections: true,
),
);
if (result?.accessToken != null) {
await _saveTokens({
'access_token': result!.accessToken,
'refresh_token': result.refreshToken,
'id_token': result.idToken,
});
return await getCurrentUser();
}
} catch (e, st) {
AppLogger.error('KeycloakAuthService: auth error', error: e, stackTrace: st);
}
return null;
}
static Future<String?>? _refreshFuture;
/// Rafraîchissement automatique du token avec verrouillage global
Future<String?> refreshToken() async {
if (_refreshFuture != null) {
AppLogger.info('KeycloakAuthService: waiting for ongoing refresh');
return await _refreshFuture;
}
_refreshFuture = _performRefresh();
try {
return await _refreshFuture;
} finally {
_refreshFuture = null;
}
}
Future<String?> _performRefresh() async {
final refresh = await _storage.read(key: _refreshK);
if (refresh == null) {
AppLogger.info('KeycloakAuthService: no refresh token available');
return null;
}
try {
AppLogger.info('KeycloakAuthService: attempting token refresh');
final response = await _dio.post(
KeycloakConfig.tokenEndpoint,
data: {
'client_id': KeycloakConfig.clientId,
'grant_type': 'refresh_token',
'refresh_token': refresh,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
validateStatus: (status) => status == 200,
),
);
if (response.statusCode == 200) {
await _saveTokens(response.data);
AppLogger.info('KeycloakAuthService: token refreshed successfully');
return response.data['access_token'];
}
} on DioException catch (e, st) {
AppLogger.error('KeycloakAuthService: refresh error ${e.response?.statusCode}', error: e, stackTrace: st);
if (e.response?.statusCode == 400) {
AppLogger.info('KeycloakAuthService: refresh token invalid or expired, logging out');
await logout();
}
} catch (e, st) {
AppLogger.error('KeycloakAuthService: critical refresh error', error: e, stackTrace: st);
}
return null;
}
/// Retourne un access token valide, en rafraîchissant automatiquement si expiré.
/// Utilisé par les datasources pour éviter d'envoyer un Bearer null ou expiré.
Future<String?> getValidAccessToken() async {
String? token = await _storage.read(key: _accessK);
if (token == null) return null;
if (JwtDecoder.isExpired(token)) {
token = await refreshToken();
}
return token;
}
/// Récupération de l'utilisateur courant + Mapage Rôles
Future<User?> getCurrentUser() async {
String? token = await _storage.read(key: _accessK);
final idToken = await _storage.read(key: _idK);
if (token == null || idToken == null) return null;
if (JwtDecoder.isExpired(token)) {
token = await refreshToken();
if (token == null) return null;
}
try {
final payload = JwtDecoder.decode(token);
final idPayload = JwtDecoder.decode(idToken);
final roles = _extractRoles(payload);
final primaryRole = KeycloakRoleMapper.mapToUserRole(roles);
AppLogger.info('KeycloakAuthService: roles mapped', tag: '${primaryRole.name}');
return User(
id: idPayload['sub'] ?? '',
email: idPayload['email'] ?? '',
firstName: idPayload['given_name'] ?? '',
lastName: idPayload['family_name'] ?? '',
primaryRole: primaryRole,
additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles),
isActive: true,
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
);
} catch (e, st) {
AppLogger.error('KeycloakAuthService: user parse error', error: e, stackTrace: st);
}
return null;
}
/// Logout : révoque la session SSO côté Keycloak via le back-channel
/// (POST /logout silencieux, sans navigateur), puis purge le stockage local.
///
/// Conforme OIDC RP-Initiated Logout. Ne lève jamais d'exception : la purge
/// locale est garantie même si Keycloak est injoignable. Le statut du
/// back-channel est tracé dans les logs pour diagnostic.
Future<void> logout() async {
final refresh = await _storage.read(key: _refreshK);
if (refresh == null || refresh.isEmpty) {
AppLogger.info('KeycloakAuthService: no refresh token, skipping backchannel logout');
} else {
try {
final response = await _dio.post(
KeycloakConfig.logoutEndpoint,
data: {
'client_id': KeycloakConfig.clientId,
'refresh_token': refresh,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
// Accepte tout statut < 600 — on interprète nous-mêmes ci-dessous.
validateStatus: (_) => true,
),
);
final code = response.statusCode ?? 0;
if (code == 200 || code == 204) {
AppLogger.info('KeycloakAuthService: SSO session revoked at Keycloak (HTTP $code)');
} else if (code == 400) {
// Refresh token déjà invalide côté Keycloak → idempotent, OK.
AppLogger.info('KeycloakAuthService: refresh token already invalid (HTTP 400) — session considered revoked');
} else {
AppLogger.error(
'KeycloakAuthService: backchannel logout returned HTTP $code'
'SSO session may still be active. Body: ${response.data}',
);
}
} catch (e, st) {
AppLogger.error(
'KeycloakAuthService: backchannel logout network error — local logout will still proceed',
error: e,
stackTrace: st,
);
}
}
// Purge locale toujours effectuée — l'utilisateur "se sent" déconnecté
// immédiatement même si le serveur n'a pas pu être notifié.
await _storage.deleteAll();
AppLogger.info('KeycloakAuthService: local session cleared');
}
Future<void> _saveTokens(Map<String, dynamic> data) async {
if (data['access_token'] != null) await _storage.write(key: _accessK, value: data['access_token']);
if (data['refresh_token'] != null) await _storage.write(key: _refreshK, value: data['refresh_token']);
if (data['id_token'] != null) await _storage.write(key: _idK, value: data['id_token']);
}
List<String> _extractRoles(Map<String, dynamic> payload) {
final roles = <String>[];
if (payload['realm_access']?['roles'] != null) {
roles.addAll((payload['realm_access']['roles'] as List).cast<String>());
}
if (payload['resource_access'] != null) {
(payload['resource_access'] as Map).values.forEach((v) {
if (v['roles'] != null) roles.addAll((v['roles'] as List).cast<String>());
});
}
return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList();
}
Future<String?> getValidToken() async {
final token = await _storage.read(key: _accessK);
if (token != null && !JwtDecoder.isExpired(token)) return token;
return await refreshToken();
}
/// Vérifie le statut du compte sur le backend UnionFlow.
///
/// Retourne un [AuthStatusResult] enrichi avec l'état d'onboarding,
/// ou `null` en cas d'erreur réseau (on ne bloque pas en cas de doute).
Future<AuthStatusResult?> getAuthStatus(String apiBaseUrl) async {
try {
final token = await getValidToken();
if (token == null) return null;
final response = await _dio.get(
'$apiBaseUrl/api/membres/mon-statut',
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (s) => s != null && s < 500,
),
);
if (response.statusCode == 200 && response.data is Map) {
final data = response.data as Map;
return AuthStatusResult(
statutCompte: (data['statutCompte'] as String?) ?? 'ACTIF',
onboardingState: (data['onboardingState'] as String?) ?? 'NO_SUBSCRIPTION',
souscriptionId: data['souscriptionId'] as String?,
waveSessionId: data['waveSessionId'] as String?,
organisationId: data['organisationId'] as String?,
typeOrganisation: data['typeOrganisation'] as String?,
premierLoginComplet: (data['premierLoginComplet'] as bool?) ?? false,
reAuthRequired: (data['reAuthRequired'] as bool?) ?? false,
);
}
} catch (e) {
AppLogger.warning('KeycloakAuthService: impossible de vérifier statut compte: $e');
}
return null;
}
}
/// Résultat enrichi de /api/membres/mon-statut
class AuthStatusResult {
final String statutCompte;
final String onboardingState;
final String? souscriptionId;
final String? waveSessionId;
final String? organisationId;
final String? typeOrganisation;
/// true si le premier login vient d'être complété (token à rafraîchir pour avoir MEMBRE/MEMBRE_ACTIF)
final bool premierLoginComplet;
/// true si une réauthentification est requise (UPDATE_PASSWORD vient d'être assigné sur un ancien compte)
final bool reAuthRequired;
const AuthStatusResult({
required this.statutCompte,
required this.onboardingState,
this.souscriptionId,
this.waveSessionId,
this.organisationId,
this.typeOrganisation,
this.premierLoginComplet = false,
this.reAuthRequired = false,
});
bool get isActive => statutCompte == 'ACTIF';
bool get isPendingOnboarding => statutCompte == 'EN_ATTENTE_VALIDATION';
bool get isBlocked => statutCompte == 'SUSPENDU' || statutCompte == 'DESACTIVE';
}