feat(admin): KeycloakAdminHttpClient + AdminUserService amélioré

- 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.
This commit is contained in:
dahoud
2026-04-15 20:23:50 +00:00
parent 9a270995ee
commit e482ad5a4d
3 changed files with 125 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
package dev.lions.unionflow.server.security; package dev.lions.unionflow.server.security;
import dev.lions.unionflow.server.service.OrganisationModuleService; import dev.lions.unionflow.server.service.OrganisationModuleService;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.Priority; import jakarta.annotation.Priority;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Priorities; import jakarta.ws.rs.Priorities;
@@ -44,6 +45,9 @@ public class ModuleAccessFilter implements ContainerRequestFilter {
@Inject @Inject
OrganisationModuleService organisationModuleService; OrganisationModuleService organisationModuleService;
@Inject
SecurityIdentity identity;
@Context @Context
ResourceInfo resourceInfo; ResourceInfo resourceInfo;
@@ -61,6 +65,11 @@ public class ModuleAccessFilter implements ContainerRequestFilter {
return; 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(); String moduleRequis = annotation.value().toUpperCase();
// 2. Extraire l'organisation active depuis le header // 2. Extraire l'organisation active depuis le header

View File

@@ -52,21 +52,11 @@ public class AdminUserService {
} }
public List<RoleDTO> getRealmRoles() { public List<RoleDTO> getRealmRoles() {
try { return roleServiceClient.getRealmRoles(DEFAULT_REALM);
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();
}
} }
public List<RoleDTO> getUserRoles(String userId) { public List<RoleDTO> getUserRoles(String userId) {
try { return roleServiceClient.getUserRealmRoles(userId, DEFAULT_REALM);
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();
}
} }
/** /**

View File

@@ -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<String> 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<String> 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<String> 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;
}
}