package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.utils.*; import io.quarkus.mailer.Mail; import io.quarkus.mailer.reactive.ReactiveMailer; import io.quarkus.runtime.annotations.RegisterForReflection; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import io.vertx.mutiny.core.Vertx; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.RoleScopeResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import java.text.Normalizer; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @ApplicationScoped public class KeycloakService { private static final Logger LOGGER = Logger.getLogger(KeycloakService.class); @Inject Keycloak keycloak; @Inject ClubService clubService; @Inject MembreService membreService; @ConfigProperty(name = "keycloak.realm") String realm; @ConfigProperty(name = "email.enabled") boolean enabled_email; @Inject ReactiveMailer reactiveMailer; @Inject Vertx vertx; public Uni getGroupFromClub(ClubModel club) { if (club.getClubId() == null) { LOGGER.infof("Creation of club group %d-%s...", club.getId(), club.getName()); return vertx.getOrCreateContext().executeBlocking(() -> { GroupRepresentation clubGroup = keycloak.realm(realm).groups().groups().stream().filter(g -> g.getName().equals("club")) .findAny() .orElseThrow(() -> new KeycloakException("Fail to fetch group %s".formatted("club"))); GroupRepresentation groupRepresentation = new GroupRepresentation(); groupRepresentation.setName(club.getId() + "-" + club.getName()); try (Response response = keycloak.realm(realm).groups().group(clubGroup.getId()).subGroup(groupRepresentation)) { if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo() .equals(Response.Status.CONFLICT)) throw new KeycloakException( "Fail to set group parent for club: %s (reason=%s)".formatted(club.getName(), response.getStatusInfo().getReasonPhrase())); } return keycloak.realm(realm).groups().group(clubGroup.getId()).getSubGroups(0, 1000, true).stream() .filter(g -> g.getName().startsWith(club.getId() + "-")).findAny() .map(GroupRepresentation::getId) .orElseThrow( () -> new KeycloakException("Fail to fetch group %s*".formatted(club.getId() + "-"))); } ).call(id -> clubService.setClubId(club.getId(), id)); } return Uni.createFrom().item(club::getClubId); } public Uni getUserFromMember(MembreModel membreModel) { if (membreModel.getUserId() == null) { return Uni.createFrom() .failure(new DInternalError("No keycloak user linked to the user id=" + membreModel.getId())); } return Uni.createFrom().item(membreModel::getUserId); } public Uni setClubGroupMembre(MembreModel membreModel, ClubModel club) { if (club == null) return getUserFromMember(membreModel).chain( userId -> vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); user.groups().stream().filter(g -> g.getPath().startsWith("/club")) .forEach(g -> user.leaveGroup(g.getId())); return "OK"; })); else return getGroupFromClub(club).chain( clubId -> getUserFromMember(membreModel).chain( userId -> vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); user.groups().stream().filter(g -> g.getPath().startsWith("/club")) .forEach(g -> user.leaveGroup(g.getId())); user.joinGroup(clubId); LOGGER.infof("Set club \"%s\" to user %s (%s)", club.getName(), userId, user.toRepresentation().getUsername()); return "OK"; }))); } public Uni setEmail(String userId, String email) { return vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); UserRepresentation user2 = user.toRepresentation(); String oldEmail = user2.getEmail(); if (email.equals(user2.getEmail())) return null; user2.setEmail(email); user2.setRequiredActions(List.of(RequiredAction.VERIFY_EMAIL.name())); user.update(user2); if (enabled_email) user.sendVerifyEmail(); return oldEmail; }).call(oldEmail -> oldEmail == null || !enabled_email ? Uni.createFrom().item("") : reactiveMailer.send( Mail.withText(oldEmail, "FFSAF - Changement de votre adresse email", String.format( """ Bonjour, Suite à la modification de votre adresse email fournie lors de votre (ré)inscription à la FFSAF, vous allez recevoir dans les prochaines minutes un email de vérification de votre nouvelle adresse sur celle-ci. Ancienne adresse email : %s Nouvelle adresse email : %s Si vous n'avez pas demandé cette modification, veuillez contacter le support à l'adresse support@ffsaf.fr. Cordialement, L'équipe de la FFSAF """, oldEmail, email) ).setFrom("FFSAF ").setReplyTo("support@ffsaf.fr") ).onFailure().invoke(e -> LOGGER.error("Fail to send email", e))); } public Uni setAutoRoleMembre(String id, RoleAsso role, GradeArbitrage gradeArbitrage) { List toRemove = new ArrayList<>(List.of("club_president", "club_tresorier", "club_secretaire", "club_respo_intra", "asseseur", "arbitre")); List toAdd = new ArrayList<>(); switch (role) { case PRESIDENT, VPRESIDENT -> toAdd.add("club_president"); case TRESORIER, VTRESORIER -> toAdd.add("club_tresorier"); case SECRETAIRE, VSECRETAIRE -> toAdd.add("club_secretaire"); case MEMBREBUREAU -> toAdd.add("club_respo_intra"); } switch (gradeArbitrage) { case ARBITRE -> toAdd.addAll(List.of("asseseur", "arbitre")); case ASSESSEUR -> toAdd.add("asseseur"); } toRemove.removeAll(toAdd); return updateRole(id, toAdd, toRemove); } public Uni> fetchCompte(String id) { return vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(id); UserRepresentation user2 = user.toRepresentation(); return new Pair<>(user, new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified())); }); } public Uni> fetchRole(String id) { return vertx.getOrCreateContext().executeBlocking(() -> keycloak.realm(realm).users().get(id).roles().realmLevel().listEffective().stream() .map(RoleRepresentation::getName).toList()); } public Uni updateRole(String id, List toAdd, List toRemove) { return vertx.getOrCreateContext().executeBlocking(() -> { RoleScopeResource resource = keycloak.realm(realm).users().get(id).roles().realmLevel(); List roles = keycloak.realm(realm).roles().list(); resource.add(roles.stream().filter(r -> toAdd.contains(r.getName())).toList()); resource.remove(roles.stream().filter(r -> toRemove.contains(r.getName())).toList()); return "OK"; }); } public Uni initCompte(long id) { return membreService.getById(id).invoke(Unchecked.consumer(membreModel -> { if (membreModel.getUserId() != null) throw new KeycloakException("User already linked to the user id=" + id); if (membreModel.getEmail() == null) throw new KeycloakException("User email is null"); if (membreModel.getFname() == null || membreModel.getLname() == null) throw new KeycloakException("User name is null"); })).chain(membreModel -> creatUser(membreModel).chain(user -> { LOGGER.infof("Set user id %s to membre %s", user.getId(), membreModel.getId()); return membreService.setUserId(membreModel.getId(), user.getId()).map(__ -> user.getId()); })); } private Uni creatUser(MembreModel membreModel) { return vertx.getOrCreateContext().executeBlocking(() -> { String login; int i = 1; do { login = makeLogin(membreModel); if (i > 1) { login += i; } i++; } while (!keycloak.realm(realm).users().searchByUsername(login, true).isEmpty()); LOGGER.infof("Creation of user %s...", login); UserRepresentation user = new UserRepresentation(); user.setUsername(login); user.setFirstName(membreModel.getFname()); user.setLastName(membreModel.getLname()); user.setEmail(membreModel.getEmail()); user.setEnabled(true); user.setRequiredActions(List.of(RequiredAction.VERIFY_EMAIL.name(), RequiredAction.UPDATE_PASSWORD.name())); try (Response response = keycloak.realm(realm).users().create(user)) { if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo() .equals(Response.Status.CONFLICT)) throw new KeycloakException("Fail to creat user %s (reason=%s)".formatted(login, response.getStatusInfo().getReasonPhrase())); } String finalLogin = login; return getUser(login).orElseThrow( () -> new KeycloakException("Fail to fetch user %s".formatted(finalLogin))); }) .call(user -> enabled_email ? vertx.getOrCreateContext().executeBlocking(() -> { keycloak.realm(realm).users().get(user.getId()) .executeActionsEmail(List.of(RequiredAction.VERIFY_EMAIL.name(), RequiredAction.UPDATE_PASSWORD.name())); return null; }) : Uni.createFrom().nullItem()) .invoke(user -> membreModel.setUserId(user.getId())) .call(user -> enabled_email ? reactiveMailer.send( Mail.withText(user.getEmail(), "FFSAF - Creation de votre compte sur l'intranet", String.format( """ Bonjour, Suite à votre première inscription à la Fédération Française de Soft Armored Fighting (FFSAF), votre compte pour accéder à l'intranet a été créé. Ce compte vous permettra de consulter vos informations, de vous inscrire aux compétitions et de consulter vos résultats. Vous allez recevoir dans les prochaines minutes un email vous demandant de vérifier votre email et de définir un mot de passe. L'intranet est accessible à l'adresse suivante : https://intra.ffsaf.fr Votre nom d'utilisateur est : %s Si vous n'avez pas demandé cette inscription, veuillez contacter le support à l'adresse support@ffsaf.fr. (Pas de panique, nous ne vous enverrons pas de message autre que ce concernant votre compte) Cordialement, L'équipe de la FFSAF """, user.getUsername()) ).setFrom("FFSAF ").setReplyTo("support@ffsaf.fr") ) : Uni.createFrom().nullItem()) .call(user -> membreService.setUserId(membreModel.getId(), user.getId())) .call(user -> setClubGroupMembre(membreModel, membreModel.getClub())); } public Uni setId(long id, String nid) { return membreService.setUserId(id, nid).map(__ -> "OK"); } public Uni removeAccount(String userId) { return vertx.getOrCreateContext().executeBlocking(() -> { try (Response response = keycloak.realm(realm).users().delete(userId)) { System.out.println(response.getStatusInfo()); if (!response.getStatusInfo().equals(Response.Status.NO_CONTENT)) throw new KeycloakException("Fail to delete user %s (reason=%s)".formatted(userId, response.getStatusInfo().getReasonPhrase())); } return null; }); } public Uni removeClubGroup(String clubId) { return vertx.getOrCreateContext().executeBlocking(() -> { keycloak.realm(realm).groups().group(clubId).remove(); return null; }); } public Uni clearUser(String userId) { List toRemove = new ArrayList<>( List.of("club_president", "club_tresorier", "club_secretaire", "club_respo_intra")); return vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); RoleScopeResource resource = user.roles().realmLevel(); List roles = keycloak.realm(realm).roles().list(); resource.remove(roles.stream().filter(r -> toRemove.contains(r.getName())).toList()); user.groups().stream().filter(g -> g.getPath().startsWith("/club")) .forEach(g -> user.leaveGroup(g.getId())); return "OK"; }); } public Optional getUser(String username) { List users = keycloak.realm(realm).users().searchByUsername(username, true); if (users.isEmpty()) return Optional.empty(); else return Optional.of(users.get(0)); } public Optional getUser(UUID userId) { UserResource user = keycloak.realm(realm).users().get(userId.toString()); if (user == null) return Optional.empty(); else return Optional.of(user.toRepresentation()); } private String makeLogin(MembreModel model) { return Normalizer.normalize( (model.getFname().toLowerCase() + "." + model.getLname().toLowerCase()).replace(' ', '_'), Normalizer.Form.NFD) .replaceAll("\\p{M}", ""); } @RegisterForReflection public record UserCompteState(Boolean enabled, String login, Boolean emailVerified) { } }