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:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user