feat(auth): premier login via AppAuth — remédiation automatique des anciens comptes

- 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
This commit is contained in:
dahoud
2026-04-05 11:11:53 +00:00
parent 6bcec363ce
commit 93fc69ec22
2 changed files with 172 additions and 15 deletions

View File

@@ -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<String, Object> 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)) {

View File

@@ -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é.
*
* <p>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.
*
* <p><strong>Remédiation automatique des anciens comptes :</strong> 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<String, List<String>> attrs = new HashMap<>(
kcUser.getAttributes() != null ? kcUser.getAttributes() : new HashMap<>());
attrs.put("premiere_password_pending", List.of("true"));
kcUser.setAttributes(attrs);
List<String> 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<String, List<String>> 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.