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:
dahoud
2026-04-05 11:13:36 +00:00
parent 5383df6dcb
commit 65b5c79c43
3 changed files with 96 additions and 35 deletions

View File

@@ -68,13 +68,13 @@ class AppRouter {
onboardingState: state.onboardingState,
organisationId: state.organisationId ?? '',
souscriptionId: state.souscriptionId,
typeOrganisation: state.typeOrganisation,
);
} else {
return const LoginPage();
}
},
),
'/dashboard': (context) => const MainNavigationLayout(),
'/login': (context) => const LoginPage(),
'/about': (context) => const AboutPage(),
'/help': (context) => const HelpSupportPage(),
@@ -85,10 +85,9 @@ class AppRouter {
'/solidarity': (context) => const DemandesAidePageWrapper(),
'/reports': (context) => const ReportsPageWrapper(),
'/finances': (context) => const CotisationsPageWrapper(),
'/my-finances': (context) => const CotisationsPageWrapper(),
'/moderation': (context) => const AdhesionsPageWrapper(),
'/communication': (context) => const ConversationsPage(),
'/org-settings': (context) => const SystemSettingsPage(),
'/adhesions': (context) => const AdhesionsPageWrapper(),
'/messages': (context) => const ConversationsPage(),
'/settings': (context) => const SystemSettingsPage(),
'/analytics': (context) {
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
@@ -102,12 +101,7 @@ class AppRouter {
}
return const LoginPage();
},
'/security': (context) => const SystemSettingsPage(),
'/system-admin': (context) => const MainNavigationLayout(),
'/global-users': (context) => const UserManagementPage(),
'/messages': (context) => const ConversationsPage(),
'/public-events': (context) => const EventsPageWrapper(),
'/contact': (context) => const HelpSupportPage(),
'/approvals': (context) => const PendingApprovalsPage(),
'/budgets': (context) => const BudgetsListPage(),
};

View File

@@ -1,4 +1,5 @@
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';
@@ -22,6 +23,7 @@ class KeycloakConfig {
@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),
@@ -31,23 +33,28 @@ class KeycloakAuthService {
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 {
/// Login via Authorization Code Flow + PKCE (AppAuth)
Future<User?> loginWithAppAuth() 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),
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 (response.statusCode == 200) {
await _saveTokens(response.data);
if (result?.accessToken != null) {
await _saveTokens({
'access_token': result!.accessToken,
'refresh_token': result.refreshToken,
'id_token': result.idToken,
});
return await getCurrentUser();
}
} catch (e, st) {
@@ -202,6 +209,9 @@ class KeycloakAuthService {
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) {
@@ -218,6 +228,11 @@ class AuthStatusResult {
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,
@@ -225,6 +240,9 @@ class AuthStatusResult {
this.souscriptionId,
this.waveSessionId,
this.organisationId,
this.typeOrganisation,
this.premierLoginComplet = false,
this.reAuthRequired = false,
});
bool get isActive => statutCompte == 'ACTIF';

View File

@@ -19,11 +19,7 @@ abstract class AuthEvent extends Equatable {
}
class AuthLoginRequested extends AuthEvent {
final String email;
final String password;
const AuthLoginRequested(this.email, this.password);
@override
List<Object?> get props => [email, password];
const AuthLoginRequested();
}
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? souscriptionId;
final String? organisationId;
final String? typeOrganisation;
const AuthPendingOnboarding({
required this.onboardingState,
this.souscriptionId,
this.organisationId,
this.typeOrganisation,
});
@override
List<Object?> get props => [onboardingState, souscriptionId, organisationId];
List<Object?> get props => [onboardingState, souscriptionId, organisationId, typeOrganisation];
}
// === BLOC ===
@@ -104,10 +102,30 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final rawUser = await _authService.login(event.email, event.password);
var rawUser = await _authService.loginWithAppAuth();
if (rawUser != null) {
// 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) {
// OrgAdmin en attente → rediriger vers l'onboarding (sans déconnecter)
@@ -120,6 +138,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
onboardingState: status.onboardingState,
souscriptionId: status.souscriptionId,
organisationId: orgId,
typeOrganisation: status.typeOrganisation,
));
return;
}
@@ -133,7 +152,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
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 token = await _authService.getValidToken();
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)
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) {
final user = await _enrichUserWithOrgContext(rawUser);
final orgId = status.organisationId ??
@@ -185,6 +223,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
onboardingState: status.onboardingState,
souscriptionId: status.souscriptionId,
organisationId: orgId,
typeOrganisation: status.typeOrganisation ?? 'ASSOCIATION',
));
return;
}
@@ -198,7 +237,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
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 token = await _authService.getValidToken();
emit(AuthAuthenticated(