feat(auth): gestion reAuthRequired + suppression flux changement mot de passe manuel
- AuthStatusResult: nouveau champ reAuthRequired (ancien compte nécessitant UPDATE_PASSWORD) - AuthBloc._onLoginRequested: si reAuthRequired → logout silencieux + re-déclenchement AppAuth automatique (Keycloak affiche l'écran de changement de mot de passe dans Chrome Custom Tab) - AuthBloc._onStatusChecked: si reAuthRequired → logout + AuthUnauthenticated (redirection login) - Remplacement du flux premierLoginComplet (boolean) par enum côté backend - Suppression de AuthPasswordChangeRequired, AuthPasswordChanging, change_password_page.dart
This commit is contained in:
@@ -68,13 +68,13 @@ class AppRouter {
|
|||||||
onboardingState: state.onboardingState,
|
onboardingState: state.onboardingState,
|
||||||
organisationId: state.organisationId ?? '',
|
organisationId: state.organisationId ?? '',
|
||||||
souscriptionId: state.souscriptionId,
|
souscriptionId: state.souscriptionId,
|
||||||
|
typeOrganisation: state.typeOrganisation,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
'/dashboard': (context) => const MainNavigationLayout(),
|
|
||||||
'/login': (context) => const LoginPage(),
|
'/login': (context) => const LoginPage(),
|
||||||
'/about': (context) => const AboutPage(),
|
'/about': (context) => const AboutPage(),
|
||||||
'/help': (context) => const HelpSupportPage(),
|
'/help': (context) => const HelpSupportPage(),
|
||||||
@@ -85,10 +85,9 @@ class AppRouter {
|
|||||||
'/solidarity': (context) => const DemandesAidePageWrapper(),
|
'/solidarity': (context) => const DemandesAidePageWrapper(),
|
||||||
'/reports': (context) => const ReportsPageWrapper(),
|
'/reports': (context) => const ReportsPageWrapper(),
|
||||||
'/finances': (context) => const CotisationsPageWrapper(),
|
'/finances': (context) => const CotisationsPageWrapper(),
|
||||||
'/my-finances': (context) => const CotisationsPageWrapper(),
|
'/adhesions': (context) => const AdhesionsPageWrapper(),
|
||||||
'/moderation': (context) => const AdhesionsPageWrapper(),
|
'/messages': (context) => const ConversationsPage(),
|
||||||
'/communication': (context) => const ConversationsPage(),
|
'/settings': (context) => const SystemSettingsPage(),
|
||||||
'/org-settings': (context) => const SystemSettingsPage(),
|
|
||||||
'/analytics': (context) {
|
'/analytics': (context) {
|
||||||
final authState = context.read<AuthBloc>().state;
|
final authState = context.read<AuthBloc>().state;
|
||||||
if (authState is AuthAuthenticated) {
|
if (authState is AuthAuthenticated) {
|
||||||
@@ -102,12 +101,7 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
},
|
},
|
||||||
'/security': (context) => const SystemSettingsPage(),
|
|
||||||
'/system-admin': (context) => const MainNavigationLayout(),
|
|
||||||
'/global-users': (context) => const UserManagementPage(),
|
'/global-users': (context) => const UserManagementPage(),
|
||||||
'/messages': (context) => const ConversationsPage(),
|
|
||||||
'/public-events': (context) => const EventsPageWrapper(),
|
|
||||||
'/contact': (context) => const HelpSupportPage(),
|
|
||||||
'/approvals': (context) => const PendingApprovalsPage(),
|
'/approvals': (context) => const PendingApprovalsPage(),
|
||||||
'/budgets': (context) => const BudgetsListPage(),
|
'/budgets': (context) => const BudgetsListPage(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
@@ -22,6 +23,7 @@ class KeycloakConfig {
|
|||||||
@lazySingleton
|
@lazySingleton
|
||||||
class KeycloakAuthService {
|
class KeycloakAuthService {
|
||||||
final Dio _dio = Dio();
|
final Dio _dio = Dio();
|
||||||
|
final FlutterAppAuth _appAuth = const FlutterAppAuth();
|
||||||
final FlutterSecureStorage _storage = const FlutterSecureStorage(
|
final FlutterSecureStorage _storage = const FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
|
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||||
@@ -31,23 +33,28 @@ class KeycloakAuthService {
|
|||||||
static const String _refreshK = 'kc_refresh';
|
static const String _refreshK = 'kc_refresh';
|
||||||
static const String _idK = 'kc_id';
|
static const String _idK = 'kc_id';
|
||||||
|
|
||||||
/// Login via Direct Access Grant (Username/Password)
|
/// Login via Authorization Code Flow + PKCE (AppAuth)
|
||||||
Future<User?> login(String username, String password) async {
|
Future<User?> loginWithAppAuth() async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post(
|
final result = await _appAuth.authorizeAndExchangeCode(
|
||||||
KeycloakConfig.tokenEndpoint,
|
AuthorizationTokenRequest(
|
||||||
data: {
|
KeycloakConfig.clientId,
|
||||||
'client_id': KeycloakConfig.clientId,
|
'dev.lions.unionflow-mobile://auth/callback',
|
||||||
'grant_type': 'password',
|
serviceConfiguration: AuthorizationServiceConfiguration(
|
||||||
'username': username,
|
authorizationEndpoint: '${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth',
|
||||||
'password': password,
|
tokenEndpoint: KeycloakConfig.tokenEndpoint,
|
||||||
'scope': KeycloakConfig.scopes,
|
),
|
||||||
},
|
scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'],
|
||||||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
additionalParameters: {'kc_locale': 'fr'},
|
||||||
|
allowInsecureConnections: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
if (result?.accessToken != null) {
|
||||||
if (response.statusCode == 200) {
|
await _saveTokens({
|
||||||
await _saveTokens(response.data);
|
'access_token': result!.accessToken,
|
||||||
|
'refresh_token': result.refreshToken,
|
||||||
|
'id_token': result.idToken,
|
||||||
|
});
|
||||||
return await getCurrentUser();
|
return await getCurrentUser();
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -202,6 +209,9 @@ class KeycloakAuthService {
|
|||||||
souscriptionId: data['souscriptionId'] as String?,
|
souscriptionId: data['souscriptionId'] as String?,
|
||||||
waveSessionId: data['waveSessionId'] as String?,
|
waveSessionId: data['waveSessionId'] as String?,
|
||||||
organisationId: data['organisationId'] 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) {
|
} catch (e) {
|
||||||
@@ -218,6 +228,11 @@ class AuthStatusResult {
|
|||||||
final String? souscriptionId;
|
final String? souscriptionId;
|
||||||
final String? waveSessionId;
|
final String? waveSessionId;
|
||||||
final String? organisationId;
|
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({
|
const AuthStatusResult({
|
||||||
required this.statutCompte,
|
required this.statutCompte,
|
||||||
@@ -225,6 +240,9 @@ class AuthStatusResult {
|
|||||||
this.souscriptionId,
|
this.souscriptionId,
|
||||||
this.waveSessionId,
|
this.waveSessionId,
|
||||||
this.organisationId,
|
this.organisationId,
|
||||||
|
this.typeOrganisation,
|
||||||
|
this.premierLoginComplet = false,
|
||||||
|
this.reAuthRequired = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isActive => statutCompte == 'ACTIF';
|
bool get isActive => statutCompte == 'ACTIF';
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ abstract class AuthEvent extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AuthLoginRequested extends AuthEvent {
|
class AuthLoginRequested extends AuthEvent {
|
||||||
final String email;
|
const AuthLoginRequested();
|
||||||
final String password;
|
|
||||||
const AuthLoginRequested(this.email, this.password);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [email, password];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
|
class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
|
||||||
@@ -80,13 +76,15 @@ class AuthPendingOnboarding extends AuthState {
|
|||||||
final String onboardingState; // NO_SUBSCRIPTION | AWAITING_PAYMENT | PAYMENT_INITIATED | AWAITING_VALIDATION
|
final String onboardingState; // NO_SUBSCRIPTION | AWAITING_PAYMENT | PAYMENT_INITIATED | AWAITING_VALIDATION
|
||||||
final String? souscriptionId;
|
final String? souscriptionId;
|
||||||
final String? organisationId;
|
final String? organisationId;
|
||||||
|
final String? typeOrganisation;
|
||||||
const AuthPendingOnboarding({
|
const AuthPendingOnboarding({
|
||||||
required this.onboardingState,
|
required this.onboardingState,
|
||||||
this.souscriptionId,
|
this.souscriptionId,
|
||||||
this.organisationId,
|
this.organisationId,
|
||||||
|
this.typeOrganisation,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [onboardingState, souscriptionId, organisationId];
|
List<Object?> get props => [onboardingState, souscriptionId, organisationId, typeOrganisation];
|
||||||
}
|
}
|
||||||
|
|
||||||
// === BLOC ===
|
// === BLOC ===
|
||||||
@@ -104,10 +102,30 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
|
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
|
||||||
emit(AuthLoading());
|
emit(AuthLoading());
|
||||||
try {
|
try {
|
||||||
final rawUser = await _authService.login(event.email, event.password);
|
var rawUser = await _authService.loginWithAppAuth();
|
||||||
if (rawUser != null) {
|
if (rawUser != null) {
|
||||||
// Vérification du statut du compte UnionFlow (indépendant de Keycloak)
|
// Vérification du statut du compte UnionFlow (indépendant de Keycloak)
|
||||||
final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl);
|
var status = await _authService.getAuthStatus(AppConfig.apiBaseUrl);
|
||||||
|
|
||||||
|
// Ancien compte détecté : UPDATE_PASSWORD vient d'être assigné dans Keycloak.
|
||||||
|
// Déclencher une nouvelle authentification AppAuth pour afficher l'écran de changement.
|
||||||
|
if (status != null && status.reAuthRequired) {
|
||||||
|
AppLogger.info('AuthBloc: réauthentification requise (ancien compte), re-déclenchement AppAuth');
|
||||||
|
await _authService.logout();
|
||||||
|
final reAuthUser = await _authService.loginWithAppAuth();
|
||||||
|
if (reAuthUser == null) {
|
||||||
|
emit(const AuthError('Connexion annulée.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rawUser = reAuthUser;
|
||||||
|
status = await _authService.getAuthStatus(AppConfig.apiBaseUrl);
|
||||||
|
// Garde-fou : si toujours reAuthRequired après la seconde tentative → erreur de config
|
||||||
|
if (status != null && status.reAuthRequired) {
|
||||||
|
AppLogger.error('AuthBloc: reAuthRequired persistant après réauthentification');
|
||||||
|
emit(const AuthError('Erreur de configuration du compte. Contactez votre administrateur.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (status != null && status.isPendingOnboarding) {
|
if (status != null && status.isPendingOnboarding) {
|
||||||
// OrgAdmin en attente → rediriger vers l'onboarding (sans déconnecter)
|
// OrgAdmin en attente → rediriger vers l'onboarding (sans déconnecter)
|
||||||
@@ -120,6 +138,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
onboardingState: status.onboardingState,
|
onboardingState: status.onboardingState,
|
||||||
souscriptionId: status.souscriptionId,
|
souscriptionId: status.souscriptionId,
|
||||||
organisationId: orgId,
|
organisationId: orgId,
|
||||||
|
typeOrganisation: status.typeOrganisation,
|
||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,7 +152,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final user = await _enrichUserWithOrgContext(rawUser);
|
// Si premier login venant d'être complété, rafraîchir le token pour obtenir
|
||||||
|
// les rôles MEMBRE + MEMBRE_ACTIF assignés par le backend lors de l'activation.
|
||||||
|
User user;
|
||||||
|
if (status != null && status.premierLoginComplet) {
|
||||||
|
await _authService.refreshToken();
|
||||||
|
final refreshedRawUser = await _authService.getCurrentUser();
|
||||||
|
user = await _enrichUserWithOrgContext(refreshedRawUser ?? rawUser);
|
||||||
|
} else {
|
||||||
|
user = await _enrichUserWithOrgContext(rawUser);
|
||||||
|
}
|
||||||
|
|
||||||
final permissions = await PermissionEngine.getEffectivePermissions(user);
|
final permissions = await PermissionEngine.getEffectivePermissions(user);
|
||||||
final token = await _authService.getValidToken();
|
final token = await _authService.getValidToken();
|
||||||
await DashboardCacheManager.invalidateForRole(user.primaryRole);
|
await DashboardCacheManager.invalidateForRole(user.primaryRole);
|
||||||
@@ -175,6 +204,15 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
// Vérification du statut du compte (au redémarrage de l'app)
|
// Vérification du statut du compte (au redémarrage de l'app)
|
||||||
final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl);
|
final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl);
|
||||||
|
|
||||||
|
// Ancien compte sans UPDATE_PASSWORD : effacer la session locale et renvoyer vers le login.
|
||||||
|
// L'utilisateur sera invité à se reconnecter — Keycloak affichera l'écran de changement de mot de passe.
|
||||||
|
if (status != null && status.reAuthRequired) {
|
||||||
|
AppLogger.info('AuthBloc: réauthentification requise au démarrage (ancien compte)');
|
||||||
|
await _authService.logout();
|
||||||
|
emit(AuthUnauthenticated());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (status != null && status.isPendingOnboarding) {
|
if (status != null && status.isPendingOnboarding) {
|
||||||
final user = await _enrichUserWithOrgContext(rawUser);
|
final user = await _enrichUserWithOrgContext(rawUser);
|
||||||
final orgId = status.organisationId ??
|
final orgId = status.organisationId ??
|
||||||
@@ -185,6 +223,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
onboardingState: status.onboardingState,
|
onboardingState: status.onboardingState,
|
||||||
souscriptionId: status.souscriptionId,
|
souscriptionId: status.souscriptionId,
|
||||||
organisationId: orgId,
|
organisationId: orgId,
|
||||||
|
typeOrganisation: status.typeOrganisation ?? 'ASSOCIATION',
|
||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -198,7 +237,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final user = await _enrichUserWithOrgContext(rawUser);
|
// Si premier login venant d'être complété, rafraîchir le token pour obtenir
|
||||||
|
// les rôles MEMBRE + MEMBRE_ACTIF assignés par le backend lors de l'activation.
|
||||||
|
User user;
|
||||||
|
if (status != null && status.premierLoginComplet) {
|
||||||
|
await _authService.refreshToken();
|
||||||
|
final refreshedRawUser = await _authService.getCurrentUser();
|
||||||
|
user = await _enrichUserWithOrgContext(refreshedRawUser ?? rawUser);
|
||||||
|
} else {
|
||||||
|
user = await _enrichUserWithOrgContext(rawUser);
|
||||||
|
}
|
||||||
|
|
||||||
final permissions = await PermissionEngine.getEffectivePermissions(user);
|
final permissions = await PermissionEngine.getEffectivePermissions(user);
|
||||||
final token = await _authService.getValidToken();
|
final token = await _authService.getValidToken();
|
||||||
emit(AuthAuthenticated(
|
emit(AuthAuthenticated(
|
||||||
|
|||||||
Reference in New Issue
Block a user