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())
|
.filter(s -> s != null && !s.isBlank())
|
||||||
.orElse("ACTIF");
|
.orElse("ACTIF");
|
||||||
|
|
||||||
// Auto-activer si le membre a été créé par un admin dont l'org a une souscription active.
|
// Premier login : Keycloak a déjà forcé UPDATE_PASSWORD dans le Chrome Custom Tab.
|
||||||
// Couvre les membres créés avant l'auto-activation à la création ET les cas limites futurs.
|
// 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()) {
|
if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) {
|
||||||
Membre m = membreOpt.get();
|
Membre m = membreOpt.get();
|
||||||
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
|
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
|
||||||
@@ -136,12 +160,13 @@ public class CompteAdherentResource {
|
|||||||
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("statutCompte", statutCompte);
|
response.put("statutCompte", statutCompte);
|
||||||
|
// changerMotDePasseRequis = false : Keycloak gère nativement le changement de mot de passe
|
||||||
// Signaler si le membre doit changer son mot de passe (premier login)
|
// via la required action UPDATE_PASSWORD dans le Chrome Custom Tab (AppAuth).
|
||||||
boolean changerMotDePasseRequis = membreOpt
|
response.put("changerMotDePasseRequis", false);
|
||||||
.map(m -> Boolean.TRUE.equals(m.getPremiereConnexion()))
|
// Indique à Flutter que le token doit être rafraîchi (nouveaux rôles MEMBRE/MEMBRE_ACTIF)
|
||||||
.orElse(false);
|
response.put("premierLoginComplet", premierLoginComplet);
|
||||||
response.put("changerMotDePasseRequis", changerMotDePasseRequis);
|
// 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
|
// Enrichir avec l'état d'onboarding pour les comptes en attente
|
||||||
if ("EN_ATTENTE_VALIDATION".equals(statutCompte)) {
|
if ("EN_ATTENTE_VALIDATION".equals(statutCompte)) {
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import jakarta.transaction.Transactional;
|
|||||||
import jakarta.ws.rs.NotFoundException;
|
import jakarta.ws.rs.NotFoundException;
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
@@ -42,6 +45,18 @@ import java.util.logging.Logger;
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class MembreKeycloakSyncService {
|
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 Logger LOGGER = Logger.getLogger(MembreKeycloakSyncService.class.getName());
|
||||||
private static final String DEFAULT_REALM = "unionflow";
|
private static final String DEFAULT_REALM = "unionflow";
|
||||||
|
|
||||||
@@ -191,14 +206,14 @@ public class MembreKeycloakSyncService {
|
|||||||
userServiceClient.updateUser(keycloakUserId, user, DEFAULT_REALM);
|
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(
|
roleServiceClient.assignRealmRoles(
|
||||||
keycloakUserId,
|
keycloakUserId,
|
||||||
DEFAULT_REALM,
|
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) {
|
} catch (Exception e) {
|
||||||
LOGGER.severe("❌ Erreur lors de l'activation Keycloak pour " + membre.getNomComplet() + ": " + e.getMessage());
|
LOGGER.severe("❌ Erreur lors de l'activation Keycloak pour " + membre.getNomComplet() + ": " + e.getMessage());
|
||||||
@@ -425,13 +440,16 @@ public class MembreKeycloakSyncService {
|
|||||||
user.setRealmName(DEFAULT_REALM);
|
user.setRealmName(DEFAULT_REALM);
|
||||||
|
|
||||||
// Mot de passe temporaire (généré aléatoirement)
|
// Mot de passe temporaire (généré aléatoirement)
|
||||||
// temporary=false : ne bloque pas le Direct Access Grant (login mobile)
|
|
||||||
String temporaryPassword = generateTemporaryPassword();
|
String temporaryPassword = generateTemporaryPassword();
|
||||||
user.setTemporaryPassword(temporaryPassword);
|
user.setTemporaryPassword(temporaryPassword);
|
||||||
|
|
||||||
// Aucune required action : le login mobile (Direct Access Grant) est bloqué
|
// UPDATE_PASSWORD : Keycloak affiche son écran de changement dans le Chrome Custom Tab
|
||||||
// si UPDATE_PASSWORD ou VERIFY_EMAIL sont présents
|
// lors du premier login via AppAuth (Authorization Code + PKCE).
|
||||||
user.setRequiredActions(List.of());
|
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
|
// Rôles par défaut pour un nouveau membre
|
||||||
user.setRealmRoles(List.of("MEMBRE")); // Rôle de base
|
user.setRealmRoles(List.of("MEMBRE")); // Rôle de base
|
||||||
@@ -503,11 +521,125 @@ public class MembreKeycloakSyncService {
|
|||||||
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
|
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
|
||||||
|
|
||||||
membre.setPremiereConnexion(false);
|
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);
|
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());
|
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é.
|
* Génère un mot de passe temporaire sécurisé.
|
||||||
* Le user sera forcé de le changer à la première connexion.
|
* Le user sera forcé de le changer à la première connexion.
|
||||||
|
|||||||
Reference in New Issue
Block a user