From e482ad5a4de924c035cd8d8cbe22490ad18b6898 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:23:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20KeycloakAdminHttpClient=20+=20Ad?= =?UTF-8?q?minUserService=20am=C3=A9lior=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeycloakAdminHttpClient (nouveau) : client HTTP natif (java.net.http.HttpClient) pour contourner les problèmes de désérialisation avec RESTEasy sur certains endpoints Keycloak 26+ (bruteForceStrategy, cpuInfo inconnus). Utilise ObjectMapper avec FAIL_ON_UNKNOWN_PROPERTIES=false. - AdminUserService : utilisation correcte de AdminUserServiceClient + AdminRoleServiceClient avec AdminServiceTokenHeadersFactory pour l'auth. - ModuleAccessFilter : améliorations de la logique @RequiresModule. --- .../server/security/ModuleAccessFilter.java | 9 ++ .../server/service/AdminUserService.java | 14 +-- .../service/KeycloakAdminHttpClient.java | 114 ++++++++++++++++++ 3 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 src/main/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClient.java diff --git a/src/main/java/dev/lions/unionflow/server/security/ModuleAccessFilter.java b/src/main/java/dev/lions/unionflow/server/security/ModuleAccessFilter.java index 5f5b4f2..3ded48f 100644 --- a/src/main/java/dev/lions/unionflow/server/security/ModuleAccessFilter.java +++ b/src/main/java/dev/lions/unionflow/server/security/ModuleAccessFilter.java @@ -1,6 +1,7 @@ package dev.lions.unionflow.server.security; import dev.lions.unionflow.server.service.OrganisationModuleService; +import io.quarkus.security.identity.SecurityIdentity; import jakarta.annotation.Priority; import jakarta.inject.Inject; import jakarta.ws.rs.Priorities; @@ -44,6 +45,9 @@ public class ModuleAccessFilter implements ContainerRequestFilter { @Inject OrganisationModuleService organisationModuleService; + @Inject + SecurityIdentity identity; + @Context ResourceInfo resourceInfo; @@ -61,6 +65,11 @@ public class ModuleAccessFilter implements ContainerRequestFilter { return; } + // SUPER_ADMIN a accès global à tous les modules sans contexte d'organisation + if (identity.hasRole("SUPER_ADMIN")) { + return; + } + String moduleRequis = annotation.value().toUpperCase(); // 2. Extraire l'organisation active depuis le header diff --git a/src/main/java/dev/lions/unionflow/server/service/AdminUserService.java b/src/main/java/dev/lions/unionflow/server/service/AdminUserService.java index 23a7a33..1fb9042 100644 --- a/src/main/java/dev/lions/unionflow/server/service/AdminUserService.java +++ b/src/main/java/dev/lions/unionflow/server/service/AdminUserService.java @@ -52,21 +52,11 @@ public class AdminUserService { } public List getRealmRoles() { - try { - return roleServiceClient.getRealmRoles(DEFAULT_REALM); - } catch (Exception e) { - LOG.warnf("Impossible de récupérer les rôles realm: %s", e.getMessage()); - return List.of(); - } + return roleServiceClient.getRealmRoles(DEFAULT_REALM); } public List getUserRoles(String userId) { - try { - return roleServiceClient.getUserRealmRoles(userId, DEFAULT_REALM); - } catch (Exception e) { - LOG.warnf("Impossible de récupérer les rôles de l'utilisateur %s: %s", userId, e.getMessage()); - return List.of(); - } + return roleServiceClient.getUserRealmRoles(userId, DEFAULT_REALM); } /** diff --git a/src/main/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClient.java b/src/main/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClient.java new file mode 100644 index 0000000..ef85fce --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClient.java @@ -0,0 +1,114 @@ +package dev.lions.unionflow.server.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * Client HTTP direct vers l'API Admin Keycloak (sans JAX-RS pour éviter les conflits ObjectMapper). + * Pattern identique à KeycloakAdminClientImpl.getAllRealms() — HttpClient Java 11 + ObjectMapper isolé. + */ +@Slf4j +@ApplicationScoped +public class KeycloakAdminHttpClient { + + @ConfigProperty(name = "keycloak.admin.url", defaultValue = "http://localhost:8180") + String keycloakUrl; + + @ConfigProperty(name = "keycloak.admin.username", defaultValue = "admin") + String adminUsername; + + @ConfigProperty(name = "keycloak.admin.password", defaultValue = "admin") + String adminPassword; + + @ConfigProperty(name = "keycloak.admin.realm", defaultValue = "unionflow") + String realm; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private final ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + /** + * Obtenir un token admin depuis le realm master via client_credentials admin-cli + */ + private String getAdminToken() throws Exception { + String body = "client_id=admin-cli" + + "&username=" + java.net.URLEncoder.encode(adminUsername, java.nio.charset.StandardCharsets.UTF_8) + + "&password=" + java.net.URLEncoder.encode(adminPassword, java.nio.charset.StandardCharsets.UTF_8) + + "&grant_type=password"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/realms/master/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("Échec authentification admin Keycloak (HTTP " + response.statusCode() + "): " + response.body()); + } + + return mapper.readTree(response.body()).get("access_token").asText(); + } + + /** + * Révoquer toutes les sessions actives du realm. + * Stratégie : lister tous les users → POST /users/{id}/logout pour chaque. + * @return nombre de sessions révoquées + */ + public int logoutAllSessions() throws Exception { + String token = getAdminToken(); + log.info("Token admin Keycloak obtenu — déconnexion de toutes les sessions du realm '{}'", realm); + + // Récupérer tous les utilisateurs (max 1000) + HttpRequest usersRequest = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/users?max=1000&enabled=true")) + .header("Authorization", "Bearer " + token) + .GET() + .timeout(Duration.ofSeconds(15)) + .build(); + + HttpResponse usersResponse = httpClient.send(usersRequest, HttpResponse.BodyHandlers.ofString()); + if (usersResponse.statusCode() != 200) { + throw new RuntimeException("Impossible de lister les utilisateurs Keycloak (HTTP " + usersResponse.statusCode() + ")"); + } + + var users = mapper.readTree(usersResponse.body()); + int loggedOut = 0; + + for (var user : users) { + String userId = user.get("id").asText(); + String username = user.has("username") ? user.get("username").asText() : userId; + + HttpRequest logoutRequest = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/users/" + userId + "/logout")) + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.noBody()) + .timeout(Duration.ofSeconds(5)) + .build(); + + HttpResponse logoutResponse = httpClient.send(logoutRequest, HttpResponse.BodyHandlers.ofString()); + if (logoutResponse.statusCode() == 204 || logoutResponse.statusCode() == 200) { + loggedOut++; + log.debug("Session révoquée pour l'utilisateur '{}'", username); + } else { + log.warn("Impossible de révoquer la session de '{}' (HTTP {})", username, logoutResponse.statusCode()); + } + } + + log.info("Déconnexion globale terminée: {}/{} utilisateur(s) déconnecté(s)", loggedOut, users.size()); + return loggedOut; + } +}