Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.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 '../models/user_role.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 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 Direct Access Grant (Username/Password)
|
||||
Future<User?> login(String username, String password) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
KeycloakConfig.tokenEndpoint,
|
||||
data: {
|
||||
'client_id': KeycloakConfig.clientId,
|
||||
'grant_type': 'password',
|
||||
'username': username,
|
||||
'password': password,
|
||||
'scope': KeycloakConfig.scopes,
|
||||
},
|
||||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
await _saveTokens(response.data);
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storage.deleteAll();
|
||||
AppLogger.info('KeycloakAuthService: 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user