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:
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user