From 93fc69ec22b699c76336f2b006611750d3921a75 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Sun, 5 Apr 2026 11:11:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(auth):=20premier=20login=20via=20AppAuth?= =?UTF-8?q?=20=E2=80=94=20rem=C3=A9diation=20automatique=20des=20anciens?= =?UTF-8?q?=20comptes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MembreKeycloakSyncService: completerPremierLogin retourne un enum PremierLoginResultat (COMPLETE / REAUTH_REQUIS / NON_APPLICABLE) au lieu d'un boolean - Détection automatique des anciens comptes (sans UPDATE_PASSWORD ni marqueur KC): assigne UPDATE_PASSWORD + attribut premiere_password_pending dans Keycloak et retourne REAUTH_REQUIS pour que Flutter re-déclenche AppAuth - Détection du mot de passe changé: marqueur présent + UPDATE_PASSWORD absent → COMPLETE - createUserDTOFromMembre: ajoute l'attribut premiere_password_pending=true à la création - CompteAdherentResource.getMonStatut: retourne reAuthRequired=true quand REAUTH_REQUIS --- .../resource/CompteAdherentResource.java | 41 ++++- .../service/MembreKeycloakSyncService.java | 146 +++++++++++++++++- 2 files changed, 172 insertions(+), 15 deletions(-) diff --git a/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java b/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java index b0cd155..ce7638a 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java @@ -114,8 +114,32 @@ public class CompteAdherentResource { .filter(s -> s != null && !s.isBlank()) .orElse("ACTIF"); - // Auto-activer si le membre a été créé par un admin dont l'org a une souscription active. - // Couvre les membres créés avant l'auto-activation à la création ET les cas limites futurs. + // Premier login : Keycloak a déjà forcé UPDATE_PASSWORD dans le Chrome Custom Tab. + // Si l'utilisateur possède un token valide (@Authenticated), c'est la preuve que + // le changement de mot de passe est complété — mettre à jour la DB et assigner les rôles. + // Pour les anciens comptes (sans UPDATE_PASSWORD assigné), le service assigne automatiquement + // la required action et retourne REAUTH_REQUIS pour que Flutter déclenche une nouvelle auth. + boolean premierLoginComplet = false; + boolean reAuthRequired = false; + if (membreOpt.isPresent() && Boolean.TRUE.equals(membreOpt.get().getPremiereConnexion())) { + Membre m = membreOpt.get(); + MembreKeycloakSyncService.PremierLoginResultat resultat = + membreKeycloakSyncService.completerPremierLogin(m.getId()); + if (resultat == MembreKeycloakSyncService.PremierLoginResultat.COMPLETE) { + premierLoginComplet = true; + // Relire le statutCompte après activation éventuelle + statutCompte = membreRepository.findByIdOptional(m.getId()) + .map(Membre::getStatutCompte) + .orElse(statutCompte); + LOG.infof("Premier login complété au statut pour %s → %s", m.getEmail(), statutCompte); + } else if (resultat == MembreKeycloakSyncService.PremierLoginResultat.REAUTH_REQUIS) { + reAuthRequired = true; + LOG.infof("Réauthentification requise pour %s (UPDATE_PASSWORD assigné)", m.getEmail()); + } + } + + // Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a souscription active + // (membres sans premiereConnexion=true ou créés avant cette logique) if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) { Membre m = membreOpt.get(); UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId()) @@ -136,12 +160,13 @@ public class CompteAdherentResource { Map response = new HashMap<>(); response.put("statutCompte", statutCompte); - - // Signaler si le membre doit changer son mot de passe (premier login) - boolean changerMotDePasseRequis = membreOpt - .map(m -> Boolean.TRUE.equals(m.getPremiereConnexion())) - .orElse(false); - response.put("changerMotDePasseRequis", changerMotDePasseRequis); + // changerMotDePasseRequis = false : Keycloak gère nativement le changement de mot de passe + // via la required action UPDATE_PASSWORD dans le Chrome Custom Tab (AppAuth). + response.put("changerMotDePasseRequis", false); + // Indique à Flutter que le token doit être rafraîchi (nouveaux rôles MEMBRE/MEMBRE_ACTIF) + response.put("premierLoginComplet", premierLoginComplet); + // Indique à Flutter qu'une réauthentification est nécessaire (UPDATE_PASSWORD vient d'être assigné) + response.put("reAuthRequired", reAuthRequired); // Enrichir avec l'état d'onboarding pour les comptes en attente if ("EN_ATTENTE_VALIDATION".equals(statutCompte)) { diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java index 1eb539c..cf97ade 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java @@ -13,7 +13,10 @@ import jakarta.transaction.Transactional; import jakarta.ws.rs.NotFoundException; import org.eclipse.microprofile.rest.client.inject.RestClient; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.logging.Logger; @@ -42,6 +45,18 @@ import java.util.logging.Logger; @ApplicationScoped public class MembreKeycloakSyncService { + /** + * Résultat du traitement du premier login. + */ + public enum PremierLoginResultat { + /** Premier login complété avec succès (token à rafraîchir côté mobile). */ + COMPLETE, + /** Réauthentification requise (UPDATE_PASSWORD assigné sur un ancien compte). */ + REAUTH_REQUIS, + /** Non applicable (premiereConnexion déjà false ou membre inexistant). */ + NON_APPLICABLE + } + private static final Logger LOGGER = Logger.getLogger(MembreKeycloakSyncService.class.getName()); private static final String DEFAULT_REALM = "unionflow"; @@ -191,14 +206,14 @@ public class MembreKeycloakSyncService { userServiceClient.updateUser(keycloakUserId, user, DEFAULT_REALM); } - // Assigner MEMBRE_ACTIF via l'endpoint dédié + // Assigner MEMBRE + MEMBRE_ACTIF — MEMBRE est requis par les @RolesAllowed du backend roleServiceClient.assignRealmRoles( keycloakUserId, DEFAULT_REALM, - new RoleServiceClient.RoleNamesRequest(List.of("MEMBRE_ACTIF")) + new RoleServiceClient.RoleNamesRequest(List.of("MEMBRE", "MEMBRE_ACTIF")) ); - LOGGER.info("✅ Rôle MEMBRE_ACTIF assigné dans Keycloak pour " + membre.getNomComplet()); + LOGGER.info("✅ Rôles MEMBRE + MEMBRE_ACTIF assignés dans Keycloak pour " + membre.getNomComplet()); } catch (Exception e) { LOGGER.severe("❌ Erreur lors de l'activation Keycloak pour " + membre.getNomComplet() + ": " + e.getMessage()); @@ -425,13 +440,16 @@ public class MembreKeycloakSyncService { user.setRealmName(DEFAULT_REALM); // Mot de passe temporaire (généré aléatoirement) - // temporary=false : ne bloque pas le Direct Access Grant (login mobile) String temporaryPassword = generateTemporaryPassword(); user.setTemporaryPassword(temporaryPassword); - // Aucune required action : le login mobile (Direct Access Grant) est bloqué - // si UPDATE_PASSWORD ou VERIFY_EMAIL sont présents - user.setRequiredActions(List.of()); + // UPDATE_PASSWORD : Keycloak affiche son écran de changement dans le Chrome Custom Tab + // lors du premier login via AppAuth (Authorization Code + PKCE). + user.setRequiredActions(List.of("UPDATE_PASSWORD")); + + // Marqueur permettant à completerPremierLogin de distinguer un nouveau compte + // (attendant le changement de mot de passe) d'un ancien compte sans UPDATE_PASSWORD. + user.setAttributes(new HashMap<>(Map.of("premiere_password_pending", List.of("true")))); // Rôles par défaut pour un nouveau membre user.setRealmRoles(List.of("MEMBRE")); // Rôle de base @@ -503,11 +521,125 @@ public class MembreKeycloakSyncService { userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest); membre.setPremiereConnexion(false); + // Auto-activation : le membre prouve son identité en changeant son mot de passe temporaire + boolean doitActiver = "EN_ATTENTE_VALIDATION".equals(membre.getStatutCompte()); + if (doitActiver) { + membre.setStatutCompte("ACTIF"); + membre.setActif(true); + LOGGER.info("Compte auto-activé lors du premier login pour: " + membre.getEmail()); + } membreRepository.persist(membre); + if (doitActiver) { + try { + activerMembreDansKeycloak(membreId); + } catch (Exception e) { + LOGGER.warning("Activation Keycloak au premier login échouée (non bloquant): " + e.getMessage()); + } + } + LOGGER.info("Mot de passe premier login changé pour: " + membre.getEmail()); } + /** + * Marque le premier login comme complété. + * + *

Keycloak ayant déjà forcé le changement de mot de passe via sa required action + * {@code UPDATE_PASSWORD} dans le Chrome Custom Tab, il suffit de mettre à jour la DB + * et d'assigner les rôles MEMBRE + MEMBRE_ACTIF si le compte était EN_ATTENTE_VALIDATION. + * + *

Remédiation automatique des anciens comptes : si le compte Keycloak + * ne possède ni l'attribut {@code premiere_password_pending} ni la required action + * {@code UPDATE_PASSWORD}, c'est un ancien compte créé avant la mise en place du flux AppAuth. + * Le service assigne automatiquement {@code UPDATE_PASSWORD} + le marqueur dans Keycloak + * et retourne {@link PremierLoginResultat#REAUTH_REQUIS} pour que Flutter déclenche une + * nouvelle authentification (qui affichera l'écran de changement de mot de passe). + * + * @param membreId UUID du membre + * @return résultat du traitement (COMPLETE, REAUTH_REQUIS ou NON_APPLICABLE) + */ + @Transactional + public PremierLoginResultat completerPremierLogin(UUID membreId) { + Membre membre = membreRepository.findByIdOptional(membreId).orElse(null); + if (membre == null || !Boolean.TRUE.equals(membre.getPremiereConnexion())) { + return PremierLoginResultat.NON_APPLICABLE; // Idempotent + } + + // Vérifier l'état du compte Keycloak pour détecter et remédier les anciens comptes + if (membre.getKeycloakId() != null) { + try { + UserDTO kcUser = userServiceClient.getUserById( + membre.getKeycloakId().toString(), DEFAULT_REALM); + + boolean hasPendingMarker = kcUser.getAttributes() != null + && kcUser.getAttributes().containsKey("premiere_password_pending"); + boolean hasUpdatePassword = kcUser.getRequiredActions() != null + && kcUser.getRequiredActions().contains("UPDATE_PASSWORD"); + + if (!hasPendingMarker && !hasUpdatePassword) { + // Ancien compte créé sans UPDATE_PASSWORD (avant le fix AppAuth). + // Assigner la required action + le marqueur, puis demander une réauthentification. + Map> attrs = new HashMap<>( + kcUser.getAttributes() != null ? kcUser.getAttributes() : new HashMap<>()); + attrs.put("premiere_password_pending", List.of("true")); + kcUser.setAttributes(attrs); + + List actions = new ArrayList<>( + kcUser.getRequiredActions() != null ? kcUser.getRequiredActions() : List.of()); + if (!actions.contains("UPDATE_PASSWORD")) { + actions.add("UPDATE_PASSWORD"); + } + kcUser.setRequiredActions(actions); + userServiceClient.updateUser(kcUser.getId(), kcUser, DEFAULT_REALM); + LOGGER.info("Ancien compte remédié — UPDATE_PASSWORD assigné dans Keycloak : " + membre.getEmail()); + return PremierLoginResultat.REAUTH_REQUIS; + } + + if (hasUpdatePassword) { + // UPDATE_PASSWORD encore présent (session pré-existante avant assignation) + // → réauthentification pour afficher l'écran de changement de mot de passe + LOGGER.info("UPDATE_PASSWORD toujours présent (session antérieure) → réauth requise : " + membre.getEmail()); + return PremierLoginResultat.REAUTH_REQUIS; + } + + if (hasPendingMarker) { + // Marqueur présent + UPDATE_PASSWORD absent → mot de passe bien changé → nettoyer + Map> attrs = new HashMap<>(kcUser.getAttributes()); + attrs.remove("premiere_password_pending"); + kcUser.setAttributes(attrs); + userServiceClient.updateUser(kcUser.getId(), kcUser, DEFAULT_REALM); + LOGGER.info("Marqueur premiere_password_pending supprimé pour : " + membre.getEmail()); + } + // hasPendingMarker=false, hasUpdatePassword=false : état inattendu mais non bloquant + // → traiter comme complété (fall-through vers activation) + + } catch (Exception e) { + LOGGER.warning("Vérification état Keycloak échouée pour " + membre.getEmail() + + " (non bloquant) : " + e.getMessage()); + // Non bloquant : continuer avec l'activation normale + } + } + + boolean doitActiver = "EN_ATTENTE_VALIDATION".equals(membre.getStatutCompte()); + membre.setPremiereConnexion(false); + if (doitActiver) { + membre.setStatutCompte("ACTIF"); + membre.setActif(true); + LOGGER.info("Compte auto-activé au premier login pour : " + membre.getEmail()); + } + membreRepository.persist(membre); + + if (doitActiver) { + try { + activerMembreDansKeycloak(membreId); + } catch (Exception e) { + LOGGER.warning("Activation Keycloak au premier login échouée (non bloquant) : " + e.getMessage()); + } + } + LOGGER.info("Premier login complété pour : " + membre.getEmail()); + return PremierLoginResultat.COMPLETE; + } + /** * Génère un mot de passe temporaire sécurisé. * Le user sera forcé de le changer à la première connexion.