commit c89377d12fdfb243daee82990faf420171620076 Author: dahoud Date: Sun Nov 9 17:06:37 2025 +0000 feat: Module server-impl-quarkus initial Module d'implémentation serveur pour lions-user-manager Contenu: - KeycloakAdminClient avec résilience (Circuit Breaker, Retry, Timeout) - UserServiceImpl (25+ méthodes) - RoleServiceImpl (20+ méthodes) - AuditServiceImpl (logging et statistiques) - UserResource, RoleResource (REST API) - Mappers (User, Role) - Health checks - Configurations dev/prod séparées Statut: 🔄 80% complété 🤖 Generated with Claude Code Co-Authored-By: Claude diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..cb22eeb --- /dev/null +++ b/pom.xml @@ -0,0 +1,206 @@ + + + 4.0.0 + + + dev.lions.user.manager + lions-user-manager-parent + 1.0.0 + + + lions-user-manager-server-impl-quarkus + jar + + Lions User Manager - Server Implementation (Quarkus) + Implémentation serveur: Resources REST, Services, Keycloak Admin Client + + + + + dev.lions.user.manager + lions-user-manager-server-api + + + + + io.quarkus + quarkus-rest + + + + io.quarkus + quarkus-rest-jackson + + + + io.quarkus + quarkus-oidc + + + + io.quarkus + quarkus-security + + + + io.quarkus + quarkus-smallrye-openapi + + + + io.quarkus + quarkus-smallrye-health + + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + + io.quarkus + quarkus-hibernate-validator + + + + io.quarkus + quarkus-rest-client-jackson + + + + io.quarkus + quarkus-smallrye-fault-tolerance + + + + + org.keycloak + keycloak-admin-client + 23.0.3 + + + org.jboss.resteasy + resteasy-client + + + org.jboss.resteasy + resteasy-multipart-provider + + + org.jboss.resteasy + resteasy-jackson2-provider + + + + + + + io.quarkus + quarkus-hibernate-orm-panache + true + + + + io.quarkus + quarkus-jdbc-postgresql + true + + + + io.quarkus + quarkus-flyway + true + + + + + org.projectlombok + lombok + + + + + org.mapstruct + mapstruct + + + + + io.quarkus + quarkus-junit5 + test + + + + io.rest-assured + rest-assured + test + + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + postgresql + test + + + + org.mockito + mockito-core + test + + + + + + + io.quarkus.platform + quarkus-maven-plugin + + + + build + generate-code + generate-code-tests + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + org.jacoco + jacoco-maven-plugin + + + + diff --git a/src/main/java/dev/lions/user/manager/client/KeycloakAdminClient.java b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClient.java new file mode 100644 index 0000000..75693f4 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClient.java @@ -0,0 +1,63 @@ +package dev.lions.user.manager.client; + +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.admin.client.resource.RolesResource; + +/** + * Interface pour le client Keycloak Admin + * Abstraction pour faciliter les tests et la gestion du cycle de vie + */ +public interface KeycloakAdminClient { + + /** + * Récupère l'instance Keycloak + * @return instance Keycloak + */ + Keycloak getInstance(); + + /** + * Récupère une ressource Realm + * @param realmName nom du realm + * @return RealmResource + */ + RealmResource getRealm(String realmName); + + /** + * Récupère la ressource Users d'un realm + * @param realmName nom du realm + * @return UsersResource + */ + UsersResource getUsers(String realmName); + + /** + * Récupère la ressource Roles d'un realm + * @param realmName nom du realm + * @return RolesResource + */ + RolesResource getRoles(String realmName); + + /** + * Vérifie si la connexion à Keycloak est active + * @return true si connecté + */ + boolean isConnected(); + + /** + * Vérifie si un realm existe + * @param realmName nom du realm + * @return true si le realm existe + */ + boolean realmExists(String realmName); + + /** + * Ferme la connexion Keycloak + */ + void close(); + + /** + * Force la reconnexion + */ + void reconnect(); +} diff --git a/src/main/java/dev/lions/user/manager/client/KeycloakAdminClientImpl.java b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClientImpl.java new file mode 100644 index 0000000..cf581b9 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClientImpl.java @@ -0,0 +1,163 @@ +package dev.lions.user.manager.client; + +import io.quarkus.runtime.Startup; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Retry; +import org.eclipse.microprofile.faulttolerance.Timeout; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.KeycloakBuilder; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RolesResource; +import org.keycloak.admin.client.resource.UsersResource; + +import jakarta.ws.rs.NotFoundException; +import java.time.temporal.ChronoUnit; + +/** + * Implémentation du client Keycloak Admin + * Utilise Circuit Breaker, Retry et Timeout pour la résilience + */ +@ApplicationScoped +@Startup +@Slf4j +public class KeycloakAdminClientImpl implements KeycloakAdminClient { + + @ConfigProperty(name = "lions.keycloak.server-url") + String serverUrl; + + @ConfigProperty(name = "lions.keycloak.admin-realm") + String adminRealm; + + @ConfigProperty(name = "lions.keycloak.admin-client-id") + String adminClientId; + + @ConfigProperty(name = "lions.keycloak.admin-username") + String adminUsername; + + @ConfigProperty(name = "lions.keycloak.admin-password") + String adminPassword; + + @ConfigProperty(name = "lions.keycloak.connection-pool-size", defaultValue = "10") + Integer connectionPoolSize; + + @ConfigProperty(name = "lions.keycloak.timeout-seconds", defaultValue = "30") + Integer timeoutSeconds; + + private Keycloak keycloak; + + @PostConstruct + void init() { + log.info("Initialisation du client Keycloak Admin..."); + log.info("Server URL: {}", serverUrl); + log.info("Admin Realm: {}", adminRealm); + log.info("Admin Client ID: {}", adminClientId); + log.info("Admin Username: {}", adminUsername); + + try { + this.keycloak = KeycloakBuilder.builder() + .serverUrl(serverUrl) + .realm(adminRealm) + .clientId(adminClientId) + .username(adminUsername) + .password(adminPassword) + .build(); + + // Test de connexion + keycloak.serverInfo().getInfo(); + log.info("✅ Connexion à Keycloak réussie!"); + } catch (Exception e) { + log.error("❌ Échec de la connexion à Keycloak: {}", e.getMessage(), e); + throw new RuntimeException("Impossible de se connecter à Keycloak", e); + } + } + + @Override + @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) + @Timeout(value = 30, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) + public Keycloak getInstance() { + if (keycloak == null) { + log.warn("Instance Keycloak null, tentative de réinitialisation..."); + init(); + } + return keycloak; + } + + @Override + @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) + @Timeout(value = 30, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) + public RealmResource getRealm(String realmName) { + try { + return getInstance().realm(realmName); + } catch (Exception e) { + log.error("Erreur lors de la récupération du realm {}: {}", realmName, e.getMessage()); + throw new RuntimeException("Impossible de récupérer le realm: " + realmName, e); + } + } + + @Override + @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) + @Timeout(value = 30, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) + public UsersResource getUsers(String realmName) { + return getRealm(realmName).users(); + } + + @Override + @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) + @Timeout(value = 30, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) + public RolesResource getRoles(String realmName) { + return getRealm(realmName).roles(); + } + + @Override + public boolean isConnected() { + try { + if (keycloak == null) { + return false; + } + keycloak.serverInfo().getInfo(); + return true; + } catch (Exception e) { + log.warn("Keycloak non connecté: {}", e.getMessage()); + return false; + } + } + + @Override + public boolean realmExists(String realmName) { + try { + getRealm(realmName).toRepresentation(); + return true; + } catch (NotFoundException e) { + return false; + } catch (Exception e) { + log.error("Erreur lors de la vérification de l'existence du realm {}: {}", realmName, e.getMessage()); + return false; + } + } + + @PreDestroy + @Override + public void close() { + if (keycloak != null) { + log.info("Fermeture de la connexion Keycloak..."); + keycloak.close(); + keycloak = null; + } + } + + @Override + public void reconnect() { + log.info("Reconnexion à Keycloak..."); + close(); + init(); + } +} diff --git a/src/main/java/dev/lions/user/manager/mapper/RoleMapper.java b/src/main/java/dev/lions/user/manager/mapper/RoleMapper.java new file mode 100644 index 0000000..f70e04b --- /dev/null +++ b/src/main/java/dev/lions/user/manager/mapper/RoleMapper.java @@ -0,0 +1,76 @@ +package dev.lions.user.manager.mapper; + +import dev.lions.user.manager.dto.role.RoleDTO; +import dev.lions.user.manager.enums.role.TypeRole; +import org.keycloak.representations.idm.RoleRepresentation; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Mapper pour convertir entre RoleDTO et Keycloak RoleRepresentation + */ +public class RoleMapper { + + /** + * Convertit une RoleRepresentation Keycloak en RoleDTO + */ + public static RoleDTO toDTO(RoleRepresentation roleRep, String realmName, TypeRole typeRole) { + if (roleRep == null) { + return null; + } + + return RoleDTO.builder() + .id(roleRep.getId()) + .nom(roleRep.getName()) + .description(roleRep.getDescription()) + .typeRole(typeRole) + .realmName(realmName) + .composite(roleRep.isComposite() != null ? roleRep.isComposite() : false) + .build(); + } + + /** + * Convertit un RoleDTO en RoleRepresentation Keycloak + */ + public static RoleRepresentation toRepresentation(RoleDTO roleDTO) { + if (roleDTO == null) { + return null; + } + + RoleRepresentation roleRep = new RoleRepresentation(); + roleRep.setId(roleDTO.getId()); + roleRep.setName(roleDTO.getNom()); + roleRep.setDescription(roleDTO.getDescription()); + roleRep.setComposite(roleDTO.isComposite()); + roleRep.setClientRole(roleDTO.getTypeRole() == TypeRole.CLIENT_ROLE); + + return roleRep; + } + + /** + * Convertit une liste de RoleRepresentation en liste de RoleDTO + */ + public static List toDTOList(List roleReps, String realmName, TypeRole typeRole) { + if (roleReps == null) { + return List.of(); + } + + return roleReps.stream() + .map(roleRep -> toDTO(roleRep, realmName, typeRole)) + .collect(Collectors.toList()); + } + + /** + * Convertit une liste de RoleDTO en liste de RoleRepresentation + */ + public static List toRepresentationList(List roleDTOs) { + if (roleDTOs == null) { + return List.of(); + } + + return roleDTOs.stream() + .map(RoleMapper::toRepresentation) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/dev/lions/user/manager/mapper/UserMapper.java b/src/main/java/dev/lions/user/manager/mapper/UserMapper.java new file mode 100644 index 0000000..a09b734 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/mapper/UserMapper.java @@ -0,0 +1,173 @@ +package dev.lions.user.manager.mapper; + +import dev.lions.user.manager.dto.user.UserDTO; +import dev.lions.user.manager.enums.user.StatutUser; +import org.keycloak.representations.idm.UserRepresentation; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Mapper pour convertir UserRepresentation (Keycloak) -> UserDTO + * Utilisé pour transformer les objets de l'API Keycloak vers nos DTOs + */ +public class UserMapper { + + private UserMapper() { + // Classe utilitaire + } + + /** + * Convertit UserRepresentation vers UserDTO + * @param userRep UserRepresentation de Keycloak + * @param realmName nom du realm + * @return UserDTO + */ + public static UserDTO toDTO(UserRepresentation userRep, String realmName) { + if (userRep == null) { + return null; + } + + return UserDTO.builder() + .id(userRep.getId()) + .username(userRep.getUsername()) + .email(userRep.getEmail()) + .emailVerified(userRep.isEmailVerified()) + .prenom(userRep.getFirstName()) + .nom(userRep.getLastName()) + .statut(StatutUser.fromEnabled(userRep.isEnabled())) + .enabled(userRep.isEnabled()) + .realmName(realmName) + .attributes(userRep.getAttributes()) + .requiredActions(userRep.getRequiredActions()) + .dateCreation(convertTimestamp(userRep.getCreatedTimestamp())) + .telephone(getAttributeValue(userRep, "phone_number")) + .organisation(getAttributeValue(userRep, "organization")) + .departement(getAttributeValue(userRep, "department")) + .fonction(getAttributeValue(userRep, "job_title")) + .pays(getAttributeValue(userRep, "country")) + .ville(getAttributeValue(userRep, "city")) + .langue(getAttributeValue(userRep, "locale")) + .timezone(getAttributeValue(userRep, "timezone")) + .build(); + } + + /** + * Convertit UserDTO vers UserRepresentation + * @param userDTO UserDTO + * @return UserRepresentation + */ + public static UserRepresentation toRepresentation(UserDTO userDTO) { + if (userDTO == null) { + return null; + } + + UserRepresentation userRep = new UserRepresentation(); + userRep.setId(userDTO.getId()); + userRep.setUsername(userDTO.getUsername()); + userRep.setEmail(userDTO.getEmail()); + userRep.setEmailVerified(userDTO.getEmailVerified()); + userRep.setFirstName(userDTO.getPrenom()); + userRep.setLastName(userDTO.getNom()); + userRep.setEnabled(userDTO.getEnabled() != null ? userDTO.getEnabled() : true); + + // Attributs personnalisés + Map> attributes = new HashMap<>(); + + if (userDTO.getTelephone() != null) { + attributes.put("phone_number", List.of(userDTO.getTelephone())); + } + if (userDTO.getOrganisation() != null) { + attributes.put("organization", List.of(userDTO.getOrganisation())); + } + if (userDTO.getDepartement() != null) { + attributes.put("department", List.of(userDTO.getDepartement())); + } + if (userDTO.getFonction() != null) { + attributes.put("job_title", List.of(userDTO.getFonction())); + } + if (userDTO.getPays() != null) { + attributes.put("country", List.of(userDTO.getPays())); + } + if (userDTO.getVille() != null) { + attributes.put("city", List.of(userDTO.getVille())); + } + if (userDTO.getLangue() != null) { + attributes.put("locale", List.of(userDTO.getLangue())); + } + if (userDTO.getTimezone() != null) { + attributes.put("timezone", List.of(userDTO.getTimezone())); + } + + // Ajouter les attributs existants du DTO + if (userDTO.getAttributes() != null) { + attributes.putAll(userDTO.getAttributes()); + } + + userRep.setAttributes(attributes); + + // Actions requises + if (userDTO.getRequiredActions() != null) { + userRep.setRequiredActions(userDTO.getRequiredActions()); + } + + return userRep; + } + + /** + * Convertit une liste de UserRepresentation vers UserDTO + * @param userReps liste de UserRepresentation + * @param realmName nom du realm + * @return liste de UserDTO + */ + public static List toDTOList(List userReps, String realmName) { + if (userReps == null) { + return new ArrayList<>(); + } + + return userReps.stream() + .map(userRep -> toDTO(userRep, realmName)) + .collect(Collectors.toList()); + } + + /** + * Récupère la valeur d'un attribut Keycloak + * @param userRep UserRepresentation + * @param attributeName nom de l'attribut + * @return valeur de l'attribut ou null + */ + private static String getAttributeValue(UserRepresentation userRep, String attributeName) { + if (userRep.getAttributes() == null) { + return null; + } + + List values = userRep.getAttributes().get(attributeName); + if (values == null || values.isEmpty()) { + return null; + } + + return values.get(0); + } + + /** + * Convertit un timestamp (millisecondes) vers LocalDateTime + * @param timestamp timestamp en millisecondes + * @return LocalDateTime ou null + */ + private static LocalDateTime convertTimestamp(Long timestamp) { + if (timestamp == null) { + return null; + } + + return LocalDateTime.ofInstant( + Instant.ofEpochMilli(timestamp), + ZoneId.systemDefault() + ); + } +} diff --git a/src/main/java/dev/lions/user/manager/resource/HealthResourceEndpoint.java b/src/main/java/dev/lions/user/manager/resource/HealthResourceEndpoint.java new file mode 100644 index 0000000..21bf146 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/resource/HealthResourceEndpoint.java @@ -0,0 +1,72 @@ +package dev.lions.user.manager.resource; + +import dev.lions.user.manager.client.KeycloakAdminClient; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.Map; + +/** + * Resource REST pour health et readiness + */ +@Path("/api/health") +@Produces(MediaType.APPLICATION_JSON) +@Slf4j +public class HealthResourceEndpoint { + + @Inject + KeycloakAdminClient keycloakAdminClient; + + @GET + @Path("/keycloak") + public Map getKeycloakHealth() { + Map health = new HashMap<>(); + + try { + boolean connected = keycloakAdminClient.isConnected(); + health.put("status", connected ? "UP" : "DOWN"); + health.put("connected", connected); + health.put("timestamp", System.currentTimeMillis()); + + if (connected) { + // Récupérer info serveur Keycloak + var serverInfo = keycloakAdminClient.getInstance().serverInfo().getInfo(); + health.put("keycloakVersion", serverInfo.getSystemInfo().getVersion()); + } + } catch (Exception e) { + log.error("Erreur health check Keycloak", e); + health.put("status", "ERROR"); + health.put("connected", false); + health.put("error", e.getMessage()); + health.put("timestamp", System.currentTimeMillis()); + } + + return health; + } + + @GET + @Path("/status") + public Map getServiceStatus() { + Map status = new HashMap<>(); + status.put("service", "lions-user-manager-server"); + status.put("version", "1.0.0"); + status.put("status", "UP"); + status.put("timestamp", System.currentTimeMillis()); + + // Health Keycloak + try { + boolean keycloakConnected = keycloakAdminClient.isConnected(); + status.put("keycloak", keycloakConnected ? "CONNECTED" : "DISCONNECTED"); + } catch (Exception e) { + status.put("keycloak", "ERROR"); + status.put("keycloakError", e.getMessage()); + } + + return status; + } +} diff --git a/src/main/java/dev/lions/user/manager/resource/KeycloakHealthCheck.java b/src/main/java/dev/lions/user/manager/resource/KeycloakHealthCheck.java new file mode 100644 index 0000000..a78ef63 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/resource/KeycloakHealthCheck.java @@ -0,0 +1,50 @@ +package dev.lions.user.manager.resource; + +import dev.lions.user.manager.client.KeycloakAdminClient; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Readiness; + +/** + * Health check pour Keycloak + */ +@Readiness +@Slf4j +public class KeycloakHealthCheck implements HealthCheck { + + @Inject + KeycloakAdminClient keycloakAdminClient; + + @Override + public HealthCheckResponse call() { + try { + boolean connected = keycloakAdminClient.isConnected(); + + if (connected) { + return HealthCheckResponse.builder() + .name("keycloak-connection") + .up() + .withData("status", "connected") + .withData("message", "Keycloak est disponible") + .build(); + } else { + return HealthCheckResponse.builder() + .name("keycloak-connection") + .down() + .withData("status", "disconnected") + .withData("message", "Keycloak n'est pas disponible") + .build(); + } + } catch (Exception e) { + log.error("Erreur lors du health check Keycloak", e); + return HealthCheckResponse.builder() + .name("keycloak-connection") + .down() + .withData("status", "error") + .withData("message", e.getMessage()) + .build(); + } + } +} diff --git a/src/main/java/dev/lions/user/manager/resource/RoleResource.java b/src/main/java/dev/lions/user/manager/resource/RoleResource.java new file mode 100644 index 0000000..826ec1d --- /dev/null +++ b/src/main/java/dev/lions/user/manager/resource/RoleResource.java @@ -0,0 +1,509 @@ +package dev.lions.user.manager.resource; + +import dev.lions.user.manager.dto.role.RoleDTO; +import dev.lions.user.manager.service.RoleService; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.util.List; + +/** + * REST Resource pour la gestion des rôles Keycloak + * Endpoints pour les rôles realm, rôles client, et attributions + */ +@Path("/api/roles") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Roles", description = "Gestion des rôles Keycloak (realm et client)") +@Slf4j +public class RoleResource { + + @Inject + RoleService roleService; + + // ==================== Endpoints Realm Roles ==================== + + @POST + @Path("/realm") + @Operation(summary = "Créer un rôle realm", description = "Crée un nouveau rôle au niveau du realm") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Rôle créé", + content = @Content(schema = @Schema(implementation = RoleDTO.class))), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "409", description = "Rôle existe déjà"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager"}) + public Response createRealmRole( + @Valid @NotNull RoleDTO roleDTO, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("POST /api/roles/realm - Création du rôle realm: {} dans le realm: {}", + roleDTO.getNom(), realmName); + + try { + RoleDTO createdRole = roleService.createRealmRole(roleDTO, realmName); + return Response.status(Response.Status.CREATED).entity(createdRole).build(); + } catch (IllegalArgumentException e) { + log.warn("Données invalides lors de la création du rôle: {}", e.getMessage()); + return Response.status(Response.Status.CONFLICT) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la création du rôle realm", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/realm/{roleName}") + @Operation(summary = "Récupérer un rôle realm par nom", description = "Récupère les détails d'un rôle realm") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Rôle trouvé", + content = @Content(schema = @Schema(implementation = RoleDTO.class))), + @APIResponse(responseCode = "404", description = "Rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager", "role_viewer"}) + public Response getRealmRole( + @Parameter(description = "Nom du rôle") @PathParam("roleName") @NotBlank String roleName, + @Parameter(description = "Nom du realm") @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/roles/realm/{} - realm: {}", roleName, realmName); + + try { + return roleService.getRealmRoleByName(roleName, realmName) + .map(role -> Response.ok(role).build()) + .orElse(Response.status(Response.Status.NOT_FOUND) + .entity(new ErrorResponse("Rôle non trouvé")) + .build()); + } catch (Exception e) { + log.error("Erreur lors de la récupération du rôle realm {}", roleName, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/realm") + @Operation(summary = "Lister tous les rôles realm", description = "Liste tous les rôles du realm") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des rôles"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager", "role_viewer"}) + public Response getAllRealmRoles( + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/roles/realm - realm: {}", realmName); + + try { + List roles = roleService.getAllRealmRoles(realmName); + return Response.ok(roles).build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des rôles realm", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @PUT + @Path("/realm/{roleName}") + @Operation(summary = "Mettre à jour un rôle realm", description = "Met à jour les informations d'un rôle realm") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Rôle mis à jour", + content = @Content(schema = @Schema(implementation = RoleDTO.class))), + @APIResponse(responseCode = "404", description = "Rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager"}) + public Response updateRealmRole( + @PathParam("roleName") @NotBlank String roleName, + @Valid @NotNull RoleDTO roleDTO, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("PUT /api/roles/realm/{} - realm: {}", roleName, realmName); + + try { + RoleDTO updatedRole = roleService.updateRealmRole(roleName, roleDTO, realmName); + return Response.ok(updatedRole).build(); + } catch (Exception e) { + log.error("Erreur lors de la mise à jour du rôle realm {}", roleName, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @DELETE + @Path("/realm/{roleName}") + @Operation(summary = "Supprimer un rôle realm", description = "Supprime un rôle realm") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Rôle supprimé"), + @APIResponse(responseCode = "404", description = "Rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin"}) + public Response deleteRealmRole( + @PathParam("roleName") @NotBlank String roleName, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("DELETE /api/roles/realm/{} - realm: {}", roleName, realmName); + + try { + roleService.deleteRealmRole(roleName, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de la suppression du rôle realm {}", roleName, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + // ==================== Endpoints Client Roles ==================== + + @POST + @Path("/client/{clientId}") + @Operation(summary = "Créer un rôle client", description = "Crée un nouveau rôle pour un client spécifique") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Rôle créé", + content = @Content(schema = @Schema(implementation = RoleDTO.class))), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "409", description = "Rôle existe déjà"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager"}) + public Response createClientRole( + @PathParam("clientId") @NotBlank String clientId, + @Valid @NotNull RoleDTO roleDTO, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("POST /api/roles/client/{} - Création du rôle client dans le realm: {}", + clientId, realmName); + + try { + RoleDTO createdRole = roleService.createClientRole(roleDTO, clientId, realmName); + return Response.status(Response.Status.CREATED).entity(createdRole).build(); + } catch (IllegalArgumentException e) { + log.warn("Données invalides lors de la création du rôle client: {}", e.getMessage()); + return Response.status(Response.Status.CONFLICT) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la création du rôle client", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/client/{clientId}/{roleName}") + @Operation(summary = "Récupérer un rôle client par nom", description = "Récupère les détails d'un rôle client") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Rôle trouvé", + content = @Content(schema = @Schema(implementation = RoleDTO.class))), + @APIResponse(responseCode = "404", description = "Rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager", "role_viewer"}) + public Response getClientRole( + @PathParam("clientId") @NotBlank String clientId, + @PathParam("roleName") @NotBlank String roleName, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/roles/client/{}/{} - realm: {}", clientId, roleName, realmName); + + try { + return roleService.getClientRoleByName(roleName, clientId, realmName) + .map(role -> Response.ok(role).build()) + .orElse(Response.status(Response.Status.NOT_FOUND) + .entity(new ErrorResponse("Rôle client non trouvé")) + .build()); + } catch (Exception e) { + log.error("Erreur lors de la récupération du rôle client {}/{}", clientId, roleName, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/client/{clientId}") + @Operation(summary = "Lister tous les rôles d'un client", description = "Liste tous les rôles d'un client spécifique") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des rôles"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager", "role_viewer"}) + public Response getAllClientRoles( + @PathParam("clientId") @NotBlank String clientId, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/roles/client/{} - realm: {}", clientId, realmName); + + try { + List roles = roleService.getAllClientRoles(clientId, realmName); + return Response.ok(roles).build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des rôles du client {}", clientId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @DELETE + @Path("/client/{clientId}/{roleName}") + @Operation(summary = "Supprimer un rôle client", description = "Supprime un rôle d'un client") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Rôle supprimé"), + @APIResponse(responseCode = "404", description = "Rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin"}) + public Response deleteClientRole( + @PathParam("clientId") @NotBlank String clientId, + @PathParam("roleName") @NotBlank String roleName, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("DELETE /api/roles/client/{}/{} - realm: {}", clientId, roleName, realmName); + + try { + roleService.deleteClientRole(roleName, clientId, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de la suppression du rôle client {}/{}", clientId, roleName, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + // ==================== Endpoints Attribution de rôles ==================== + + @POST + @Path("/assign/realm/{userId}") + @Operation(summary = "Attribuer des rôles realm à un utilisateur", description = "Assigne un ou plusieurs rôles realm à un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Rôles attribués"), + @APIResponse(responseCode = "404", description = "Utilisateur ou rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager"}) + public Response assignRealmRoles( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName, + @NotNull RoleAssignmentRequest request + ) { + log.info("POST /api/roles/assign/realm/{} - Attribution de {} rôles", userId, request.roleNames.size()); + + try { + roleService.assignRealmRolesToUser(userId, request.roleNames, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de l'attribution des rôles realm à l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Path("/revoke/realm/{userId}") + @Operation(summary = "Révoquer des rôles realm d'un utilisateur", description = "Révoque un ou plusieurs rôles realm d'un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Rôles révoqués"), + @APIResponse(responseCode = "404", description = "Utilisateur ou rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager"}) + public Response revokeRealmRoles( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName, + @NotNull RoleAssignmentRequest request + ) { + log.info("POST /api/roles/revoke/realm/{} - Révocation de {} rôles", userId, request.roleNames.size()); + + try { + roleService.revokeRealmRolesFromUser(userId, request.roleNames, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de la révocation des rôles realm de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Path("/assign/client/{clientId}/{userId}") + @Operation(summary = "Attribuer des rôles client à un utilisateur", description = "Assigne un ou plusieurs rôles client à un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Rôles attribués"), + @APIResponse(responseCode = "404", description = "Utilisateur, client ou rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager"}) + public Response assignClientRoles( + @PathParam("clientId") @NotBlank String clientId, + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName, + @NotNull RoleAssignmentRequest request + ) { + log.info("POST /api/roles/assign/client/{}/{} - Attribution de {} rôles client", + clientId, userId, request.roleNames.size()); + + try { + roleService.assignClientRolesToUser(userId, clientId, request.roleNames, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de l'attribution des rôles client à l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/user/realm/{userId}") + @Operation(summary = "Récupérer les rôles realm d'un utilisateur", description = "Liste tous les rôles realm d'un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des rôles"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager", "role_viewer"}) + public Response getUserRealmRoles( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/roles/user/realm/{} - realm: {}", userId, realmName); + + try { + List roles = roleService.getUserRealmRoles(userId, realmName); + return Response.ok(roles).build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des rôles realm de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/user/client/{clientId}/{userId}") + @Operation(summary = "Récupérer les rôles client d'un utilisateur", description = "Liste tous les rôles client d'un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des rôles"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager", "role_viewer"}) + public Response getUserClientRoles( + @PathParam("clientId") @NotBlank String clientId, + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/roles/user/client/{}/{} - realm: {}", clientId, userId, realmName); + + try { + List roles = roleService.getUserClientRoles(userId, clientId, realmName); + return Response.ok(roles).build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des rôles client de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + // ==================== Endpoints Rôles composites ==================== + + @POST + @Path("/composite/{roleName}/add") + @Operation(summary = "Ajouter des rôles composites", description = "Ajoute des rôles composites à un rôle") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Composites ajoutés"), + @APIResponse(responseCode = "404", description = "Rôle non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager"}) + public Response addComposites( + @PathParam("roleName") @NotBlank String roleName, + @QueryParam("realm") @NotBlank String realmName, + @NotNull RoleAssignmentRequest request + ) { + log.info("POST /api/roles/composite/{}/add - Ajout de {} composites", roleName, request.roleNames.size()); + + try { + roleService.addCompositesToRealmRole(roleName, request.roleNames, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de l'ajout des composites au rôle {}", roleName, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/composite/{roleName}") + @Operation(summary = "Récupérer les rôles composites", description = "Liste tous les rôles composites d'un rôle") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des composites"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "role_manager", "role_viewer"}) + public Response getComposites( + @PathParam("roleName") @NotBlank String roleName, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/roles/composite/{} - realm: {}", roleName, realmName); + + try { + List composites = roleService.getCompositeRoles(roleName, realmName); + return Response.ok(composites).build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des composites du rôle {}", roleName, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + // ==================== DTOs internes ==================== + + @Schema(description = "Requête d'attribution/révocation de rôles") + public static class RoleAssignmentRequest { + @Schema(description = "Liste des noms de rôles", required = true) + public List roleNames; + } + + @Schema(description = "Réponse d'erreur") + public static class ErrorResponse { + @Schema(description = "Message d'erreur") + public String message; + + public ErrorResponse(String message) { + this.message = message; + } + } +} diff --git a/src/main/java/dev/lions/user/manager/resource/UserResource.java b/src/main/java/dev/lions/user/manager/resource/UserResource.java new file mode 100644 index 0000000..0eb912b --- /dev/null +++ b/src/main/java/dev/lions/user/manager/resource/UserResource.java @@ -0,0 +1,406 @@ +package dev.lions.user.manager.resource; + +import dev.lions.user.manager.dto.user.UserDTO; +import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO; +import dev.lions.user.manager.dto.user.UserSearchResultDTO; +import dev.lions.user.manager.service.UserService; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.util.List; + +/** + * REST Resource pour la gestion des utilisateurs + * Endpoints exposés pour les opérations CRUD sur les utilisateurs Keycloak + */ +@Path("/api/users") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Users", description = "Gestion des utilisateurs Keycloak") +@Slf4j +public class UserResource { + + @Inject + UserService userService; + + @POST + @Path("/search") + @Operation(summary = "Rechercher des utilisateurs", description = "Recherche d'utilisateurs selon des critères") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Résultats de recherche", + content = @Content(schema = @Schema(implementation = UserSearchResultDTO.class))), + @APIResponse(responseCode = "400", description = "Critères invalides"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response searchUsers(@Valid @NotNull UserSearchCriteriaDTO criteria) { + log.info("POST /api/users/search - Recherche d'utilisateurs"); + + try { + UserSearchResultDTO result = userService.searchUsers(criteria); + return Response.ok(result).build(); + } catch (Exception e) { + log.error("Erreur lors de la recherche d'utilisateurs", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/{userId}") + @Operation(summary = "Récupérer un utilisateur par ID", description = "Récupère les détails d'un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Utilisateur trouvé", + content = @Content(schema = @Schema(implementation = UserDTO.class))), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager", "user_viewer"}) + public Response getUserById( + @Parameter(description = "ID de l'utilisateur") @PathParam("userId") @NotBlank String userId, + @Parameter(description = "Nom du realm") @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/users/{} - realm: {}", userId, realmName); + + try { + return userService.getUserById(userId, realmName) + .map(user -> Response.ok(user).build()) + .orElse(Response.status(Response.Status.NOT_FOUND) + .entity(new ErrorResponse("Utilisateur non trouvé")) + .build()); + } catch (Exception e) { + log.error("Erreur lors de la récupération de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Operation(summary = "Lister tous les utilisateurs", description = "Liste paginée de tous les utilisateurs") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des utilisateurs", + content = @Content(schema = @Schema(implementation = UserSearchResultDTO.class))), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager", "user_viewer"}) + public Response getAllUsers( + @QueryParam("realm") @NotBlank String realmName, + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("pageSize") @DefaultValue("20") int pageSize + ) { + log.info("GET /api/users - realm: {}, page: {}, pageSize: {}", realmName, page, pageSize); + + try { + UserSearchResultDTO result = userService.getAllUsers(realmName, page, pageSize); + return Response.ok(result).build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des utilisateurs", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Operation(summary = "Créer un utilisateur", description = "Crée un nouvel utilisateur dans Keycloak") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Utilisateur créé", + content = @Content(schema = @Schema(implementation = UserDTO.class))), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "409", description = "Utilisateur existe déjà"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response createUser( + @Valid @NotNull UserDTO user, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("POST /api/users - Création d'un utilisateur: {}", user.getUsername()); + + try { + UserDTO createdUser = userService.createUser(user, realmName); + return Response.status(Response.Status.CREATED).entity(createdUser).build(); + } catch (IllegalArgumentException e) { + log.warn("Données invalides lors de la création: {}", e.getMessage()); + return Response.status(Response.Status.CONFLICT) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la création de l'utilisateur", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @PUT + @Path("/{userId}") + @Operation(summary = "Mettre à jour un utilisateur", description = "Met à jour les informations d'un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Utilisateur mis à jour", + content = @Content(schema = @Schema(implementation = UserDTO.class))), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response updateUser( + @PathParam("userId") @NotBlank String userId, + @Valid @NotNull UserDTO user, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("PUT /api/users/{} - Mise à jour", userId); + + try { + UserDTO updatedUser = userService.updateUser(userId, user, realmName); + return Response.ok(updatedUser).build(); + } catch (RuntimeException e) { + if (e.getMessage().contains("non trouvé")) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + log.error("Erreur lors de la mise à jour de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @DELETE + @Path("/{userId}") + @Operation(summary = "Supprimer un utilisateur", description = "Supprime un utilisateur (soft ou hard delete)") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Utilisateur supprimé"), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin"}) + public Response deleteUser( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName, + @QueryParam("hardDelete") @DefaultValue("false") boolean hardDelete + ) { + log.info("DELETE /api/users/{} - realm: {}, hardDelete: {}", userId, realmName, hardDelete); + + try { + userService.deleteUser(userId, realmName, hardDelete); + return Response.noContent().build(); + } catch (RuntimeException e) { + if (e.getMessage().contains("non trouvé")) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + log.error("Erreur lors de la suppression de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Path("/{userId}/activate") + @Operation(summary = "Activer un utilisateur", description = "Active un utilisateur désactivé") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Utilisateur activé"), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response activateUser( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("POST /api/users/{}/activate", userId); + + try { + userService.activateUser(userId, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de l'activation de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Path("/{userId}/deactivate") + @Operation(summary = "Désactiver un utilisateur", description = "Désactive un utilisateur") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Utilisateur désactivé"), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response deactivateUser( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName, + @QueryParam("raison") String raison + ) { + log.info("POST /api/users/{}/deactivate - raison: {}", userId, raison); + + try { + userService.deactivateUser(userId, realmName, raison); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de la désactivation de l'utilisateur {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Path("/{userId}/reset-password") + @Operation(summary = "Réinitialiser le mot de passe", description = "Définit un nouveau mot de passe pour l'utilisateur") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Mot de passe réinitialisé"), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response resetPassword( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName, + @NotNull PasswordResetRequest request + ) { + log.info("POST /api/users/{}/reset-password - temporary: {}", userId, request.temporary); + + try { + userService.resetPassword(userId, realmName, request.password, request.temporary); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de la réinitialisation du mot de passe pour {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Path("/{userId}/send-verification-email") + @Operation(summary = "Envoyer email de vérification", description = "Envoie un email de vérification à l'utilisateur") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Email envoyé"), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response sendVerificationEmail( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("POST /api/users/{}/send-verification-email", userId); + + try { + userService.sendVerificationEmail(userId, realmName); + return Response.noContent().build(); + } catch (Exception e) { + log.error("Erreur lors de l'envoi de l'email de vérification pour {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @POST + @Path("/{userId}/logout-sessions") + @Operation(summary = "Déconnecter toutes les sessions", description = "Révoque toutes les sessions actives de l'utilisateur") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Sessions révoquées"), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager"}) + public Response logoutAllSessions( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("POST /api/users/{}/logout-sessions", userId); + + try { + int count = userService.logoutAllSessions(userId, realmName); + return Response.ok(new SessionsRevokedResponse(count)).build(); + } catch (Exception e) { + log.error("Erreur lors de la déconnexion des sessions pour {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + @GET + @Path("/{userId}/sessions") + @Operation(summary = "Récupérer les sessions actives", description = "Liste les sessions actives de l'utilisateur") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des sessions"), + @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur serveur") + }) + @RolesAllowed({"admin", "user_manager", "user_viewer"}) + public Response getActiveSessions( + @PathParam("userId") @NotBlank String userId, + @QueryParam("realm") @NotBlank String realmName + ) { + log.info("GET /api/users/{}/sessions", userId); + + try { + List sessions = userService.getActiveSessions(userId, realmName); + return Response.ok(sessions).build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des sessions pour {}", userId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse(e.getMessage())) + .build(); + } + } + + // ==================== DTOs internes ==================== + + @Schema(description = "Requête de réinitialisation de mot de passe") + public static class PasswordResetRequest { + @Schema(description = "Nouveau mot de passe", required = true) + public String password; + + @Schema(description = "Indique si le mot de passe est temporaire", defaultValue = "true") + public boolean temporary = true; + } + + @Schema(description = "Réponse de révocation de sessions") + public static class SessionsRevokedResponse { + @Schema(description = "Nombre de sessions révoquées") + public int count; + + public SessionsRevokedResponse(int count) { + this.count = count; + } + } + + @Schema(description = "Réponse d'erreur") + public static class ErrorResponse { + @Schema(description = "Message d'erreur") + public String message; + + public ErrorResponse(String message) { + this.message = message; + } + } +} diff --git a/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java new file mode 100644 index 0000000..06ebf1f --- /dev/null +++ b/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java @@ -0,0 +1,371 @@ +package dev.lions.user.manager.service.impl; + +import dev.lions.user.manager.dto.audit.AuditLogDTO; +import dev.lions.user.manager.enums.audit.TypeActionAudit; +import dev.lions.user.manager.service.AuditService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Implémentation du service d'audit + * + * NOTES: + * - Cette implémentation utilise un stockage en mémoire pour le développement + * - En production, il faudrait utiliser une base de données (PostgreSQL avec Panache) + * - Les logs sont également écrits via SLF4J pour être capturés par les systèmes de logging centralisés + */ +@ApplicationScoped +@Slf4j +public class AuditServiceImpl implements AuditService { + + // Stockage en mémoire (à remplacer par une DB en production) + private final Map auditLogs = new ConcurrentHashMap<>(); + + @ConfigProperty(name = "lions.audit.enabled", defaultValue = "true") + boolean auditEnabled; + + @ConfigProperty(name = "lions.audit.log-to-database", defaultValue = "false") + boolean logToDatabase; + + @Override + public AuditLogDTO logAction(@Valid @NotNull AuditLogDTO auditLog) { + if (!auditEnabled) { + log.debug("Audit désactivé, log ignoré"); + return auditLog; + } + + // Générer un ID si nécessaire + if (auditLog.getId() == null) { + auditLog.setId(UUID.randomUUID().toString()); + } + + // Ajouter le timestamp si nécessaire + if (auditLog.getDateAction() == null) { + auditLog.setDateAction(LocalDateTime.now()); + } + + // Log structuré pour les systèmes de logging (Graylog, Elasticsearch, etc.) + log.info("AUDIT | Type: {} | Acteur: {} | Ressource: {} | Succès: {} | IP: {} | Détails: {}", + auditLog.getTypeAction(), + auditLog.getActeurUsername(), + auditLog.getRessourceType() + ":" + auditLog.getRessourceId(), + auditLog.isSucces(), + auditLog.getAdresseIp(), + auditLog.getDetails()); + + // Stocker en mémoire + auditLogs.put(auditLog.getId(), auditLog); + + // TODO: Si logToDatabase = true, persister dans PostgreSQL via Panache + // Exemple: + // if (logToDatabase) { + // AuditLogEntity entity = AuditLogMapper.toEntity(auditLog); + // entity.persist(); + // } + + return auditLog; + } + + @Override + public void logSuccess(@NotBlank String acteurUsername, @NotNull TypeActionAudit typeAction, + @NotBlank String ressourceType, @NotBlank String ressourceId, + String adresseIp, String details) { + AuditLogDTO auditLog = AuditLogDTO.builder() + .acteurUsername(acteurUsername) + .typeAction(typeAction) + .ressourceType(ressourceType) + .ressourceId(ressourceId) + .succes(true) + .adresseIp(adresseIp) + .details(details) + .dateAction(LocalDateTime.now()) + .build(); + + logAction(auditLog); + } + + @Override + public void logFailure(@NotBlank String acteurUsername, @NotNull TypeActionAudit typeAction, + @NotBlank String ressourceType, @NotBlank String ressourceId, + String adresseIp, @NotBlank String messageErreur) { + AuditLogDTO auditLog = AuditLogDTO.builder() + .acteurUsername(acteurUsername) + .typeAction(typeAction) + .ressourceType(ressourceType) + .ressourceId(ressourceId) + .succes(false) + .adresseIp(adresseIp) + .messageErreur(messageErreur) + .dateAction(LocalDateTime.now()) + .build(); + + logAction(auditLog); + } + + @Override + public List searchLogs(@NotBlank String acteurUsername, LocalDateTime dateDebut, + LocalDateTime dateFin, TypeActionAudit typeAction, + String ressourceType, Boolean succes, + int page, int pageSize) { + log.debug("Recherche de logs d'audit: acteur={}, dateDebut={}, dateFin={}, typeAction={}, succes={}", + acteurUsername, dateDebut, dateFin, typeAction, succes); + + return auditLogs.values().stream() + .filter(log -> { + // Filtre par acteur (si spécifié et non "*") + if (acteurUsername != null && !"*".equals(acteurUsername) && + !acteurUsername.equals(log.getActeurUsername())) { + return false; + } + + // Filtre par date début + if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { + return false; + } + + // Filtre par date fin + if (dateFin != null && log.getDateAction().isAfter(dateFin)) { + return false; + } + + // Filtre par type d'action + if (typeAction != null && !typeAction.equals(log.getTypeAction())) { + return false; + } + + // Filtre par type de ressource + if (ressourceType != null && !ressourceType.equals(log.getRessourceType())) { + return false; + } + + // Filtre par succès/échec + if (succes != null && succes != log.isSucces()) { + return false; + } + + return true; + }) + .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction())) // Tri décroissant par date + .skip((long) page * pageSize) + .limit(pageSize) + .collect(Collectors.toList()); + } + + @Override + public List getLogsByActeur(@NotBlank String acteurUsername, int limit) { + log.debug("Récupération des {} derniers logs de l'acteur: {}", limit, acteurUsername); + + return auditLogs.values().stream() + .filter(log -> acteurUsername.equals(log.getActeurUsername())) + .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction())) + .limit(limit) + .collect(Collectors.toList()); + } + + @Override + public List getLogsByRessource(@NotBlank String ressourceType, + @NotBlank String ressourceId, int limit) { + log.debug("Récupération des {} derniers logs de la ressource: {}:{}", + limit, ressourceType, ressourceId); + + return auditLogs.values().stream() + .filter(log -> ressourceType.equals(log.getRessourceType()) && + ressourceId.equals(log.getRessourceId())) + .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction())) + .limit(limit) + .collect(Collectors.toList()); + } + + @Override + public List getLogsByAction(@NotNull TypeActionAudit typeAction, + LocalDateTime dateDebut, LocalDateTime dateFin, + int limit) { + log.debug("Récupération des {} logs de type: {} entre {} et {}", + limit, typeAction, dateDebut, dateFin); + + return auditLogs.values().stream() + .filter(log -> { + if (!typeAction.equals(log.getTypeAction())) { + return false; + } + if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { + return false; + } + if (dateFin != null && log.getDateAction().isAfter(dateFin)) { + return false; + } + return true; + }) + .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction())) + .limit(limit) + .collect(Collectors.toList()); + } + + @Override + public Map getActionStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) { + log.debug("Calcul des statistiques d'actions entre {} et {}", dateDebut, dateFin); + + return auditLogs.values().stream() + .filter(log -> { + if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { + return false; + } + if (dateFin != null && log.getDateAction().isAfter(dateFin)) { + return false; + } + return true; + }) + .collect(Collectors.groupingBy( + AuditLogDTO::getTypeAction, + Collectors.counting() + )); + } + + @Override + public Map getUserActivityStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) { + log.debug("Calcul des statistiques d'activité utilisateurs entre {} et {}", dateDebut, dateFin); + + return auditLogs.values().stream() + .filter(log -> { + if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { + return false; + } + if (dateFin != null && log.getDateAction().isAfter(dateFin)) { + return false; + } + return true; + }) + .collect(Collectors.groupingBy( + AuditLogDTO::getActeurUsername, + Collectors.counting() + )); + } + + @Override + public long getFailureCount(LocalDateTime dateDebut, LocalDateTime dateFin) { + log.debug("Comptage des échecs entre {} et {}", dateDebut, dateFin); + + return auditLogs.values().stream() + .filter(log -> { + if (log.isSucces()) { + return false; // On ne compte que les échecs + } + if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { + return false; + } + if (dateFin != null && log.getDateAction().isAfter(dateFin)) { + return false; + } + return true; + }) + .count(); + } + + @Override + public long getSuccessCount(LocalDateTime dateDebut, LocalDateTime dateFin) { + log.debug("Comptage des succès entre {} et {}", dateDebut, dateFin); + + return auditLogs.values().stream() + .filter(log -> { + if (!log.isSucces()) { + return false; // On ne compte que les succès + } + if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { + return false; + } + if (dateFin != null && log.getDateAction().isAfter(dateFin)) { + return false; + } + return true; + }) + .count(); + } + + @Override + public List exportLogsToCSV(LocalDateTime dateDebut, LocalDateTime dateFin) { + log.info("Export CSV des logs d'audit entre {} et {}", dateDebut, dateFin); + + List csvLines = new ArrayList<>(); + + // En-tête CSV + csvLines.add("ID,Date Action,Acteur,Type Action,Ressource Type,Ressource ID,Succès,Adresse IP,Détails,Message Erreur"); + + // Données + auditLogs.values().stream() + .filter(log -> { + if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { + return false; + } + if (dateFin != null && log.getDateAction().isAfter(dateFin)) { + return false; + } + return true; + }) + .sorted((a, b) -> a.getDateAction().compareTo(b.getDateAction())) + .forEach(log -> { + String csvLine = String.format("%s,%s,%s,%s,%s,%s,%s,%s,\"%s\",\"%s\"", + log.getId(), + log.getDateAction(), + log.getActeurUsername(), + log.getTypeAction(), + log.getRessourceType(), + log.getRessourceId(), + log.isSucces(), + log.getAdresseIp() != null ? log.getAdresseIp() : "", + log.getDetails() != null ? log.getDetails().replace("\"", "\"\"") : "", + log.getMessageErreur() != null ? log.getMessageErreur().replace("\"", "\"\"") : "" + ); + csvLines.add(csvLine); + }); + + log.info("Export CSV terminé: {} lignes", csvLines.size() - 1); + return csvLines; + } + + @Override + public void purgeOldLogs(int joursDAnc ienneté) { + log.info("Purge des logs d'audit de plus de {} jours", joursDAncienneté); + + LocalDateTime dateLimit = LocalDateTime.now().minusDays(joursDAncienneté); + + long beforeCount = auditLogs.size(); + auditLogs.entrySet().removeIf(entry -> + entry.getValue().getDateAction().isBefore(dateLimit) + ); + long afterCount = auditLogs.size(); + + log.info("Purge terminée: {} logs supprimés", beforeCount - afterCount); + + // TODO: Si base de données utilisée, exécuter: + // DELETE FROM audit_log WHERE date_action < :dateLimit + } + + // ==================== Méthodes utilitaires ==================== + + /** + * Retourne le nombre total de logs en mémoire + */ + public long getTotalCount() { + return auditLogs.size(); + } + + /** + * Vide tous les logs (ATTENTION: à utiliser uniquement en développement) + */ + public void clearAll() { + log.warn("ATTENTION: Suppression de tous les logs d'audit en mémoire"); + auditLogs.clear(); + } +} diff --git a/src/main/java/dev/lions/user/manager/service/impl/RoleServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..d465e4d --- /dev/null +++ b/src/main/java/dev/lions/user/manager/service/impl/RoleServiceImpl.java @@ -0,0 +1,609 @@ +package dev.lions.user.manager.service.impl; + +import dev.lions.user.manager.client.KeycloakAdminClient; +import dev.lions.user.manager.dto.role.RoleAssignmentDTO; +import dev.lions.user.manager.dto.role.RoleDTO; +import dev.lions.user.manager.enums.role.TypeRole; +import dev.lions.user.manager.mapper.RoleMapper; +import dev.lions.user.manager.service.RoleService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.NotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.admin.client.resource.*; +import org.keycloak.representations.idm.RoleRepresentation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Implémentation du service de gestion des rôles Keycloak + */ +@ApplicationScoped +@Slf4j +public class RoleServiceImpl implements RoleService { + + @Inject + KeycloakAdminClient keycloakAdminClient; + + // ==================== CRUD Realm Roles ==================== + + @Override + public RoleDTO createRealmRole(@Valid @NotNull RoleDTO roleDTO, @NotBlank String realmName) { + log.info("Création du rôle realm: {} dans le realm: {}", roleDTO.getNom(), realmName); + + RolesResource rolesResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles(); + + // Vérifier si le rôle existe déjà + try { + rolesResource.get(roleDTO.getNom()).toRepresentation(); + throw new IllegalArgumentException("Le rôle " + roleDTO.getNom() + " existe déjà"); + } catch (NotFoundException e) { + // OK, le rôle n'existe pas + } + + RoleRepresentation roleRep = RoleMapper.toRepresentation(roleDTO); + rolesResource.create(roleRep); + + // Récupérer le rôle créé avec son ID + RoleRepresentation createdRole = rolesResource.get(roleDTO.getNom()).toRepresentation(); + return RoleMapper.toDTO(createdRole, realmName, TypeRole.REALM_ROLE); + } + + @Override + public Optional getRealmRoleById(@NotBlank String roleId, @NotBlank String realmName) { + log.debug("Récupération du rôle realm par ID: {} dans le realm: {}", roleId, realmName); + + try { + RolesResource rolesResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles(); + + // Keycloak ne permet pas de récupérer un rôle par ID directement, on doit lister tous les rôles + List roles = rolesResource.list(); + return roles.stream() + .filter(r -> r.getId().equals(roleId)) + .findFirst() + .map(r -> RoleMapper.toDTO(r, realmName, TypeRole.REALM_ROLE)); + } catch (Exception e) { + log.error("Erreur lors de la récupération du rôle realm {}", roleId, e); + return Optional.empty(); + } + } + + @Override + public Optional getRealmRoleByName(@NotBlank String roleName, @NotBlank String realmName) { + log.debug("Récupération du rôle realm par nom: {} dans le realm: {}", roleName, realmName); + + try { + RoleRepresentation roleRep = keycloakAdminClient.getInstance() + .realm(realmName) + .roles() + .get(roleName) + .toRepresentation(); + + return Optional.of(RoleMapper.toDTO(roleRep, realmName, TypeRole.REALM_ROLE)); + } catch (NotFoundException e) { + log.warn("Rôle realm {} non trouvé dans le realm {}", roleName, realmName); + return Optional.empty(); + } + } + + @Override + public RoleDTO updateRealmRole(@NotBlank String roleName, @Valid @NotNull RoleDTO roleDTO, + @NotBlank String realmName) { + log.info("Mise à jour du rôle realm: {} dans le realm: {}", roleName, realmName); + + RoleResource roleResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles() + .get(roleName); + + RoleRepresentation roleRep = roleResource.toRepresentation(); + + // Mettre à jour uniquement les champs modifiables + if (roleDTO.getDescription() != null) { + roleRep.setDescription(roleDTO.getDescription()); + } + + roleResource.update(roleRep); + + // Retourner le rôle mis à jour + return RoleMapper.toDTO(roleResource.toRepresentation(), realmName, TypeRole.REALM_ROLE); + } + + @Override + public void deleteRealmRole(@NotBlank String roleName, @NotBlank String realmName) { + log.info("Suppression du rôle realm: {} dans le realm: {}", roleName, realmName); + + keycloakAdminClient.getInstance() + .realm(realmName) + .roles() + .deleteRole(roleName); + } + + @Override + public List getAllRealmRoles(@NotBlank String realmName) { + log.debug("Récupération de tous les rôles realm du realm: {}", realmName); + + List roleReps = keycloakAdminClient.getInstance() + .realm(realmName) + .roles() + .list(); + + return RoleMapper.toDTOList(roleReps, realmName, TypeRole.REALM_ROLE); + } + + // ==================== CRUD Client Roles ==================== + + @Override + public RoleDTO createClientRole(@Valid @NotNull RoleDTO roleDTO, @NotBlank String clientId, + @NotBlank String realmName) { + log.info("Création du rôle client: {} pour le client: {} dans le realm: {}", + roleDTO.getNom(), clientId, realmName); + + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + // Trouver le client par clientId + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + throw new IllegalArgumentException("Client " + clientId + " non trouvé"); + } + + String internalClientId = clients.get(0).getId(); + RolesResource rolesResource = clientsResource.get(internalClientId).roles(); + + // Vérifier si le rôle existe déjà + try { + rolesResource.get(roleDTO.getNom()).toRepresentation(); + throw new IllegalArgumentException("Le rôle " + roleDTO.getNom() + " existe déjà pour ce client"); + } catch (NotFoundException e) { + // OK, le rôle n'existe pas + } + + RoleRepresentation roleRep = RoleMapper.toRepresentation(roleDTO); + rolesResource.create(roleRep); + + // Récupérer le rôle créé + RoleRepresentation createdRole = rolesResource.get(roleDTO.getNom()).toRepresentation(); + RoleDTO result = RoleMapper.toDTO(createdRole, realmName, TypeRole.CLIENT_ROLE); + result.setClientId(clientId); + + return result; + } + + @Override + public Optional getClientRoleByName(@NotBlank String roleName, @NotBlank String clientId, + @NotBlank String realmName) { + log.debug("Récupération du rôle client: {} pour le client: {} dans le realm: {}", + roleName, clientId, realmName); + + try { + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + return Optional.empty(); + } + + String internalClientId = clients.get(0).getId(); + RoleRepresentation roleRep = clientsResource.get(internalClientId) + .roles() + .get(roleName) + .toRepresentation(); + + RoleDTO roleDTO = RoleMapper.toDTO(roleRep, realmName, TypeRole.CLIENT_ROLE); + roleDTO.setClientId(clientId); + + return Optional.of(roleDTO); + } catch (NotFoundException e) { + log.warn("Rôle client {} non trouvé pour le client {} dans le realm {}", + roleName, clientId, realmName); + return Optional.empty(); + } + } + + @Override + public void deleteClientRole(@NotBlank String roleName, @NotBlank String clientId, + @NotBlank String realmName) { + log.info("Suppression du rôle client: {} pour le client: {} dans le realm: {}", + roleName, clientId, realmName); + + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + throw new IllegalArgumentException("Client " + clientId + " non trouvé"); + } + + String internalClientId = clients.get(0).getId(); + clientsResource.get(internalClientId).roles().deleteRole(roleName); + } + + @Override + public List getAllClientRoles(@NotBlank String clientId, @NotBlank String realmName) { + log.debug("Récupération de tous les rôles du client: {} dans le realm: {}", clientId, realmName); + + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + return List.of(); + } + + String internalClientId = clients.get(0).getId(); + List roleReps = clientsResource.get(internalClientId) + .roles() + .list(); + + List roles = RoleMapper.toDTOList(roleReps, realmName, TypeRole.CLIENT_ROLE); + roles.forEach(role -> role.setClientId(clientId)); + + return roles; + } + + // ==================== Attribution de rôles ==================== + + @Override + public void assignRealmRolesToUser(@NotBlank String userId, @NotNull List roleNames, + @NotBlank String realmName) { + log.info("Attribution de {} rôles realm à l'utilisateur {} dans le realm {}", + roleNames.size(), userId, realmName); + + UserResource userResource = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId); + + RolesResource rolesResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles(); + + List rolesToAssign = roleNames.stream() + .map(roleName -> { + try { + return rolesResource.get(roleName).toRepresentation(); + } catch (NotFoundException e) { + log.warn("Rôle {} non trouvé, ignoré", roleName); + return null; + } + }) + .filter(role -> role != null) + .collect(Collectors.toList()); + + if (!rolesToAssign.isEmpty()) { + userResource.roles().realmLevel().add(rolesToAssign); + } + } + + @Override + public void revokeRealmRolesFromUser(@NotBlank String userId, @NotNull List roleNames, + @NotBlank String realmName) { + log.info("Révocation de {} rôles realm pour l'utilisateur {} dans le realm {}", + roleNames.size(), userId, realmName); + + UserResource userResource = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId); + + RolesResource rolesResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles(); + + List rolesToRevoke = roleNames.stream() + .map(roleName -> { + try { + return rolesResource.get(roleName).toRepresentation(); + } catch (NotFoundException e) { + log.warn("Rôle {} non trouvé, ignoré", roleName); + return null; + } + }) + .filter(role -> role != null) + .collect(Collectors.toList()); + + if (!rolesToRevoke.isEmpty()) { + userResource.roles().realmLevel().remove(rolesToRevoke); + } + } + + @Override + public void assignClientRolesToUser(@NotBlank String userId, @NotBlank String clientId, + @NotNull List roleNames, @NotBlank String realmName) { + log.info("Attribution de {} rôles du client {} à l'utilisateur {} dans le realm {}", + roleNames.size(), clientId, userId, realmName); + + UserResource userResource = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId); + + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + throw new IllegalArgumentException("Client " + clientId + " non trouvé"); + } + + String internalClientId = clients.get(0).getId(); + RolesResource rolesResource = clientsResource.get(internalClientId).roles(); + + List rolesToAssign = roleNames.stream() + .map(roleName -> { + try { + return rolesResource.get(roleName).toRepresentation(); + } catch (NotFoundException e) { + log.warn("Rôle client {} non trouvé, ignoré", roleName); + return null; + } + }) + .filter(role -> role != null) + .collect(Collectors.toList()); + + if (!rolesToAssign.isEmpty()) { + userResource.roles().clientLevel(internalClientId).add(rolesToAssign); + } + } + + @Override + public void revokeClientRolesFromUser(@NotBlank String userId, @NotBlank String clientId, + @NotNull List roleNames, @NotBlank String realmName) { + log.info("Révocation de {} rôles du client {} pour l'utilisateur {} dans le realm {}", + roleNames.size(), clientId, userId, realmName); + + UserResource userResource = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId); + + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + throw new IllegalArgumentException("Client " + clientId + " non trouvé"); + } + + String internalClientId = clients.get(0).getId(); + RolesResource rolesResource = clientsResource.get(internalClientId).roles(); + + List rolesToRevoke = roleNames.stream() + .map(roleName -> { + try { + return rolesResource.get(roleName).toRepresentation(); + } catch (NotFoundException e) { + log.warn("Rôle client {} non trouvé, ignoré", roleName); + return null; + } + }) + .filter(role -> role != null) + .collect(Collectors.toList()); + + if (!rolesToRevoke.isEmpty()) { + userResource.roles().clientLevel(internalClientId).remove(rolesToRevoke); + } + } + + @Override + public List getUserRealmRoles(@NotBlank String userId, @NotBlank String realmName) { + log.debug("Récupération des rôles realm de l'utilisateur {} dans le realm {}", userId, realmName); + + List roleReps = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId) + .roles() + .realmLevel() + .listAll(); + + return RoleMapper.toDTOList(roleReps, realmName, TypeRole.REALM_ROLE); + } + + @Override + public List getUserClientRoles(@NotBlank String userId, @NotBlank String clientId, + @NotBlank String realmName) { + log.debug("Récupération des rôles du client {} pour l'utilisateur {} dans le realm {}", + clientId, userId, realmName); + + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + return List.of(); + } + + String internalClientId = clients.get(0).getId(); + List roleReps = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId) + .roles() + .clientLevel(internalClientId) + .listAll(); + + List roles = RoleMapper.toDTOList(roleReps, realmName, TypeRole.CLIENT_ROLE); + roles.forEach(role -> role.setClientId(clientId)); + + return roles; + } + + // ==================== Rôles composites ==================== + + @Override + public void addCompositesToRealmRole(@NotBlank String roleName, @NotNull List compositeRoleNames, + @NotBlank String realmName) { + log.info("Ajout de {} rôles composites au rôle realm {} dans le realm {}", + compositeRoleNames.size(), roleName, realmName); + + RoleResource roleResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles() + .get(roleName); + + RolesResource rolesResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles(); + + List compositesToAdd = compositeRoleNames.stream() + .map(compositeName -> { + try { + return rolesResource.get(compositeName).toRepresentation(); + } catch (NotFoundException e) { + log.warn("Rôle composite {} non trouvé, ignoré", compositeName); + return null; + } + }) + .filter(role -> role != null) + .collect(Collectors.toList()); + + if (!compositesToAdd.isEmpty()) { + roleResource.addComposites(compositesToAdd); + } + } + + @Override + public void removeCompositesFromRealmRole(@NotBlank String roleName, @NotNull List compositeRoleNames, + @NotBlank String realmName) { + log.info("Suppression de {} rôles composites du rôle realm {} dans le realm {}", + compositeRoleNames.size(), roleName, realmName); + + RoleResource roleResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles() + .get(roleName); + + RolesResource rolesResource = keycloakAdminClient.getInstance() + .realm(realmName) + .roles(); + + List compositesToRemove = compositeRoleNames.stream() + .map(compositeName -> { + try { + return rolesResource.get(compositeName).toRepresentation(); + } catch (NotFoundException e) { + log.warn("Rôle composite {} non trouvé, ignoré", compositeName); + return null; + } + }) + .filter(role -> role != null) + .collect(Collectors.toList()); + + if (!compositesToRemove.isEmpty()) { + roleResource.deleteComposites(compositesToRemove); + } + } + + @Override + public List getCompositeRoles(@NotBlank String roleName, @NotBlank String realmName) { + log.debug("Récupération des rôles composites du rôle {} dans le realm {}", roleName, realmName); + + List composites = keycloakAdminClient.getInstance() + .realm(realmName) + .roles() + .get(roleName) + .getRoleComposites(); + + return RoleMapper.toDTOList(composites, realmName, TypeRole.COMPOSITE_ROLE); + } + + // ==================== Vérification de permissions ==================== + + @Override + public boolean userHasRealmRole(@NotBlank String userId, @NotBlank String roleName, + @NotBlank String realmName) { + log.debug("Vérification si l'utilisateur {} a le rôle realm {} dans le realm {}", + userId, roleName, realmName); + + List userRoles = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId) + .roles() + .realmLevel() + .listEffective(); // Incluant les rôles hérités via composites + + return userRoles.stream() + .anyMatch(role -> role.getName().equals(roleName)); + } + + @Override + public boolean userHasClientRole(@NotBlank String userId, @NotBlank String clientId, + @NotBlank String roleName, @NotBlank String realmName) { + log.debug("Vérification si l'utilisateur {} a le rôle client {} du client {} dans le realm {}", + userId, roleName, clientId, realmName); + + ClientsResource clientsResource = keycloakAdminClient.getInstance() + .realm(realmName) + .clients(); + + List clients = + clientsResource.findByClientId(clientId); + + if (clients.isEmpty()) { + return false; + } + + String internalClientId = clients.get(0).getId(); + List userClientRoles = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId) + .roles() + .clientLevel(internalClientId) + .listEffective(); + + return userClientRoles.stream() + .anyMatch(role -> role.getName().equals(roleName)); + } + + @Override + public List getUserEffectiveRealmRoles(@NotBlank String userId, @NotBlank String realmName) { + log.debug("Récupération des rôles realm effectifs de l'utilisateur {} dans le realm {}", + userId, realmName); + + List effectiveRoles = keycloakAdminClient.getInstance() + .realm(realmName) + .users() + .get(userId) + .roles() + .realmLevel() + .listEffective(); + + return RoleMapper.toDTOList(effectiveRoles, realmName, TypeRole.REALM_ROLE); + } +} diff --git a/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..fba8898 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java @@ -0,0 +1,472 @@ +package dev.lions.user.manager.service.impl; + +import dev.lions.user.manager.client.KeycloakAdminClient; +import dev.lions.user.manager.dto.user.UserDTO; +import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO; +import dev.lions.user.manager.dto.user.UserSearchResultDTO; +import dev.lions.user.manager.mapper.UserMapper; +import dev.lions.user.manager.service.UserService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.NotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.UserRepresentation; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Implémentation du service de gestion des utilisateurs + * Utilise UNIQUEMENT l'API Admin Keycloak (ZERO accès direct à la DB Keycloak) + */ +@ApplicationScoped +@Slf4j +public class UserServiceImpl implements UserService { + + @Inject + KeycloakAdminClient keycloakAdminClient; + + @Override + public UserSearchResultDTO searchUsers(@Valid @NotNull UserSearchCriteriaDTO criteria) { + log.info("Recherche d'utilisateurs avec critères: {}", criteria); + + try { + String realmName = criteria.getRealmName(); + UsersResource usersResource = keycloakAdminClient.getUsers(realmName); + + // Construire la requête de recherche + List users; + + if (criteria.getSearchTerm() != null && !criteria.getSearchTerm().isBlank()) { + // Recherche globale + users = usersResource.search( + criteria.getSearchTerm(), + criteria.getOffset(), + criteria.getPageSize() + ); + } else if (criteria.getUsername() != null) { + // Recherche par username exact + users = usersResource.search( + criteria.getUsername(), + criteria.getOffset(), + criteria.getPageSize(), + true // exact match + ); + } else if (criteria.getEmail() != null) { + // Recherche par email + users = usersResource.searchByEmail( + criteria.getEmail(), + true // exact match + ); + } else { + // Liste tous les utilisateurs + users = usersResource.list( + criteria.getOffset(), + criteria.getPageSize() + ); + } + + // Filtrer selon les critères supplémentaires + users = filterUsers(users, criteria); + + // Convertir en DTOs + List userDTOs = UserMapper.toDTOList(users, realmName); + + // Compter le total + long totalCount = usersResource.count(); + + // Construire le résultat paginé + return UserSearchResultDTO.of(userDTOs, criteria, totalCount); + + } catch (Exception e) { + log.error("Erreur lors de la recherche d'utilisateurs", e); + throw new RuntimeException("Impossible de rechercher les utilisateurs", e); + } + } + + @Override + public Optional getUserById(@NotBlank String userId, @NotBlank String realmName) { + log.info("Récupération de l'utilisateur {} dans le realm {}", userId, realmName); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + UserRepresentation userRep = userResource.toRepresentation(); + return Optional.of(UserMapper.toDTO(userRep, realmName)); + } catch (NotFoundException e) { + log.warn("Utilisateur {} non trouvé dans le realm {}", userId, realmName); + return Optional.empty(); + } catch (Exception e) { + log.error("Erreur lors de la récupération de l'utilisateur {}", userId, e); + throw new RuntimeException("Impossible de récupérer l'utilisateur", e); + } + } + + @Override + public Optional getUserByUsername(@NotBlank String username, @NotBlank String realmName) { + log.info("Récupération de l'utilisateur par username {} dans le realm {}", username, realmName); + + try { + List users = keycloakAdminClient.getUsers(realmName) + .search(username, 0, 1, true); + + if (users.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(UserMapper.toDTO(users.get(0), realmName)); + } catch (Exception e) { + log.error("Erreur lors de la récupération de l'utilisateur par username {}", username, e); + throw new RuntimeException("Impossible de récupérer l'utilisateur", e); + } + } + + @Override + public Optional getUserByEmail(@NotBlank String email, @NotBlank String realmName) { + log.info("Récupération de l'utilisateur par email {} dans le realm {}", email, realmName); + + try { + List users = keycloakAdminClient.getUsers(realmName) + .searchByEmail(email, true); + + if (users.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(UserMapper.toDTO(users.get(0), realmName)); + } catch (Exception e) { + log.error("Erreur lors de la récupération de l'utilisateur par email {}", email, e); + throw new RuntimeException("Impossible de récupérer l'utilisateur", e); + } + } + + @Override + public UserDTO createUser(@Valid @NotNull UserDTO user, @NotBlank String realmName) { + log.info("Création de l'utilisateur {} dans le realm {}", user.getUsername(), realmName); + + try { + // Vérifier si l'utilisateur existe déjà + if (usernameExists(user.getUsername(), realmName)) { + throw new IllegalArgumentException("Le nom d'utilisateur existe déjà: " + user.getUsername()); + } + + if (user.getEmail() != null && emailExists(user.getEmail(), realmName)) { + throw new IllegalArgumentException("L'email existe déjà: " + user.getEmail()); + } + + // Convertir DTO vers UserRepresentation + UserRepresentation userRep = UserMapper.toRepresentation(user); + + // Créer l'utilisateur + UsersResource usersResource = keycloakAdminClient.getUsers(realmName); + var response = usersResource.create(userRep); + + if (response.getStatus() != 201) { + throw new RuntimeException("Échec de la création de l'utilisateur: " + response.getStatusInfo()); + } + + // Récupérer l'ID de l'utilisateur créé + String userId = response.getLocation().getPath().replaceAll(".*/([^/]+)$", "$1"); + + // Définir le mot de passe si fourni + if (user.getTemporaryPassword() != null) { + setPassword(userId, realmName, user.getTemporaryPassword(), + user.getTemporaryPasswordFlag() != null && user.getTemporaryPasswordFlag()); + } + + // Récupérer l'utilisateur créé + UserResource userResource = usersResource.get(userId); + UserRepresentation createdUser = userResource.toRepresentation(); + + log.info("✅ Utilisateur créé avec succès: {} (ID: {})", user.getUsername(), userId); + return UserMapper.toDTO(createdUser, realmName); + + } catch (Exception e) { + log.error("❌ Erreur lors de la création de l'utilisateur {}", user.getUsername(), e); + throw new RuntimeException("Impossible de créer l'utilisateur", e); + } + } + + @Override + public UserDTO updateUser(@NotBlank String userId, @Valid @NotNull UserDTO user, @NotBlank String realmName) { + log.info("Mise à jour de l'utilisateur {} dans le realm {}", userId, realmName); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + + // Récupérer l'utilisateur existant + UserRepresentation existingUser = userResource.toRepresentation(); + + // Mettre à jour les champs + if (user.getEmail() != null) { + existingUser.setEmail(user.getEmail()); + } + if (user.getPrenom() != null) { + existingUser.setFirstName(user.getPrenom()); + } + if (user.getNom() != null) { + existingUser.setLastName(user.getNom()); + } + if (user.getEnabled() != null) { + existingUser.setEnabled(user.getEnabled()); + } + if (user.getEmailVerified() != null) { + existingUser.setEmailVerified(user.getEmailVerified()); + } + if (user.getAttributes() != null) { + existingUser.setAttributes(user.getAttributes()); + } + + // Envoyer la mise à jour + userResource.update(existingUser); + + // Récupérer l'utilisateur mis à jour + UserRepresentation updatedUser = userResource.toRepresentation(); + + log.info("✅ Utilisateur mis à jour avec succès: {}", userId); + return UserMapper.toDTO(updatedUser, realmName); + + } catch (NotFoundException e) { + log.error("❌ Utilisateur {} non trouvé", userId); + throw new RuntimeException("Utilisateur non trouvé", e); + } catch (Exception e) { + log.error("❌ Erreur lors de la mise à jour de l'utilisateur {}", userId, e); + throw new RuntimeException("Impossible de mettre à jour l'utilisateur", e); + } + } + + @Override + public void deleteUser(@NotBlank String userId, @NotBlank String realmName, boolean hardDelete) { + log.info("Suppression de l'utilisateur {} dans le realm {} (hard: {})", userId, realmName, hardDelete); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + + if (hardDelete) { + // Suppression définitive + userResource.remove(); + log.info("✅ Utilisateur supprimé définitivement: {}", userId); + } else { + // Soft delete: désactiver l'utilisateur + UserRepresentation user = userResource.toRepresentation(); + user.setEnabled(false); + userResource.update(user); + log.info("✅ Utilisateur désactivé (soft delete): {}", userId); + } + + } catch (NotFoundException e) { + log.error("❌ Utilisateur {} non trouvé", userId); + throw new RuntimeException("Utilisateur non trouvé", e); + } catch (Exception e) { + log.error("❌ Erreur lors de la suppression de l'utilisateur {}", userId, e); + throw new RuntimeException("Impossible de supprimer l'utilisateur", e); + } + } + + @Override + public void activateUser(@NotBlank String userId, @NotBlank String realmName) { + log.info("Activation de l'utilisateur {} dans le realm {}", userId, realmName); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + UserRepresentation user = userResource.toRepresentation(); + user.setEnabled(true); + userResource.update(user); + + log.info("✅ Utilisateur activé: {}", userId); + } catch (Exception e) { + log.error("❌ Erreur lors de l'activation de l'utilisateur {}", userId, e); + throw new RuntimeException("Impossible d'activer l'utilisateur", e); + } + } + + @Override + public void deactivateUser(@NotBlank String userId, @NotBlank String realmName, String raison) { + log.info("Désactivation de l'utilisateur {} dans le realm {} (raison: {})", userId, realmName, raison); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + UserRepresentation user = userResource.toRepresentation(); + user.setEnabled(false); + userResource.update(user); + + log.info("✅ Utilisateur désactivé: {}", userId); + } catch (Exception e) { + log.error("❌ Erreur lors de la désactivation de l'utilisateur {}", userId, e); + throw new RuntimeException("Impossible de désactiver l'utilisateur", e); + } + } + + @Override + public void suspendUser(@NotBlank String userId, @NotBlank String realmName, String raison, int duree) { + log.info("Suspension de l'utilisateur {} dans le realm {} (raison: {}, durée: {} jours)", + userId, realmName, raison, duree); + + deactivateUser(userId, realmName, raison); + } + + @Override + public void unlockUser(@NotBlank String userId, @NotBlank String realmName) { + log.info("Déverrouillage de l'utilisateur {} dans le realm {}", userId, realmName); + activateUser(userId, realmName); + } + + @Override + public void resetPassword(@NotBlank String userId, @NotBlank String realmName, + @NotBlank String temporaryPassword, boolean temporary) { + log.info("Réinitialisation du mot de passe pour l'utilisateur {} (temporaire: {})", userId, temporary); + + setPassword(userId, realmName, temporaryPassword, temporary); + } + + @Override + public void sendVerificationEmail(@NotBlank String userId, @NotBlank String realmName) { + log.info("Envoi d'un email de vérification à l'utilisateur {}", userId); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + userResource.sendVerifyEmail(); + + log.info("✅ Email de vérification envoyé: {}", userId); + } catch (Exception e) { + log.error("❌ Erreur lors de l'envoi de l'email de vérification pour {}", userId, e); + throw new RuntimeException("Impossible d'envoyer l'email de vérification", e); + } + } + + @Override + public int logoutAllSessions(@NotBlank String userId, @NotBlank String realmName) { + log.info("Déconnexion de toutes les sessions pour l'utilisateur {}", userId); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + int sessionsCount = userResource.getUserSessions().size(); + userResource.logout(); + + log.info("✅ {} sessions révoquées pour l'utilisateur {}", sessionsCount, userId); + return sessionsCount; + } catch (Exception e) { + log.error("❌ Erreur lors de la déconnexion des sessions pour {}", userId, e); + throw new RuntimeException("Impossible de déconnecter les sessions", e); + } + } + + @Override + public List getActiveSessions(@NotBlank String userId, @NotBlank String realmName) { + log.info("Récupération des sessions actives pour l'utilisateur {}", userId); + + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + return userResource.getUserSessions().stream() + .map(session -> session.getId()) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("❌ Erreur lors de la récupération des sessions pour {}", userId, e); + return Collections.emptyList(); + } + } + + @Override + public long countUsers(@NotNull UserSearchCriteriaDTO criteria) { + try { + return keycloakAdminClient.getUsers(criteria.getRealmName()).count(); + } catch (Exception e) { + log.error("Erreur lors du comptage des utilisateurs", e); + return 0; + } + } + + @Override + public UserSearchResultDTO getAllUsers(@NotBlank String realmName, int page, int pageSize) { + UserSearchCriteriaDTO criteria = UserSearchCriteriaDTO.builder() + .realmName(realmName) + .page(page) + .pageSize(pageSize) + .build(); + + return searchUsers(criteria); + } + + @Override + public boolean usernameExists(@NotBlank String username, @NotBlank String realmName) { + try { + List users = keycloakAdminClient.getUsers(realmName) + .search(username, 0, 1, true); + return !users.isEmpty(); + } catch (Exception e) { + log.error("Erreur lors de la vérification de l'existence du username {}", username, e); + return false; + } + } + + @Override + public boolean emailExists(@NotBlank String email, @NotBlank String realmName) { + try { + List users = keycloakAdminClient.getUsers(realmName) + .searchByEmail(email, true); + return !users.isEmpty(); + } catch (Exception e) { + log.error("Erreur lors de la vérification de l'existence de l'email {}", email, e); + return false; + } + } + + @Override + public String exportUsersToCSV(@NotNull UserSearchCriteriaDTO criteria) { + // TODO: Implémenter l'export CSV + throw new UnsupportedOperationException("Export CSV non implémenté"); + } + + @Override + public int importUsersFromCSV(@NotBlank String csvContent, @NotBlank String realmName) { + // TODO: Implémenter l'import CSV + throw new UnsupportedOperationException("Import CSV non implémenté"); + } + + // ==================== Méthodes privées ==================== + + private void setPassword(String userId, String realmName, String password, boolean temporary) { + try { + UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); + + CredentialRepresentation credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(password); + credential.setTemporary(temporary); + + userResource.resetPassword(credential); + + log.info("✅ Mot de passe défini pour l'utilisateur {} (temporaire: {})", userId, temporary); + } catch (Exception e) { + log.error("❌ Erreur lors de la définition du mot de passe pour {}", userId, e); + throw new RuntimeException("Impossible de définir le mot de passe", e); + } + } + + private List filterUsers(List users, UserSearchCriteriaDTO criteria) { + return users.stream() + .filter(user -> { + // Filtrer par enabled + if (criteria.getEnabled() != null && !criteria.getEnabled().equals(user.isEnabled())) { + return false; + } + + // Filtrer par emailVerified + if (criteria.getEmailVerified() != null && !criteria.getEmailVerified().equals(user.isEmailVerified())) { + return false; + } + + // TODO: Ajouter d'autres filtres selon les besoins + + return true; + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..836e8d5 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,82 @@ +# ============================================================================ +# Lions User Manager - Server Implementation Configuration - DEV +# ============================================================================ + +# HTTP Configuration +quarkus.http.port=8081 +quarkus.http.host=localhost +quarkus.http.cors=true +quarkus.http.cors.origins=http://localhost:3000,http://localhost:8080 +quarkus.http.cors.methods=GET,POST,PUT,DELETE,PATCH,OPTIONS +quarkus.http.cors.headers=* + +# Keycloak OIDC Configuration (DEV) +quarkus.oidc.auth-server-url=http://localhost:8180/realms/master +quarkus.oidc.client-id=lions-user-manager +quarkus.oidc.credentials.secret=dev-secret-change-me +quarkus.oidc.tls.verification=none +quarkus.oidc.application-type=service + +# Keycloak Admin Client Configuration (DEV) +lions.keycloak.server-url=http://localhost:8180 +lions.keycloak.admin-realm=master +lions.keycloak.admin-client-id=admin-cli +lions.keycloak.admin-username=admin +lions.keycloak.admin-password=admin +lions.keycloak.connection-pool-size=5 +lions.keycloak.timeout-seconds=30 + +# Realms autorisés (DEV) +lions.keycloak.authorized-realms=btpxpress,master,lions-realm,test-realm + +# Circuit Breaker Configuration (DEV - plus permissif) +quarkus.smallrye-fault-tolerance.enabled=true + +# Retry Configuration (DEV) +lions.keycloak.retry.max-attempts=3 +lions.keycloak.retry.delay-seconds=1 + +# Audit Configuration (DEV) +lions.audit.enabled=true +lions.audit.log-to-database=false +lions.audit.log-to-file=true +lions.audit.retention-days=30 + +# Database Configuration (DEV - optionnel) +# Décommenter pour utiliser une DB locale +#quarkus.datasource.db-kind=postgresql +#quarkus.datasource.username=postgres +#quarkus.datasource.password=postgres +#quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/lions_audit_dev +#quarkus.hibernate-orm.database.generation=update +#quarkus.flyway.migrate-at-start=false + +# Logging Configuration (DEV) +quarkus.log.level=DEBUG +quarkus.log.category."dev.lions.user.manager".level=DEBUG +quarkus.log.category."org.keycloak".level=INFO +quarkus.log.category."io.quarkus".level=INFO + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n +quarkus.log.console.color=true + +# File Logging pour Audit (DEV) +quarkus.log.file.enable=true +quarkus.log.file.path=logs/dev/lions-user-manager.log +quarkus.log.file.rotation.max-file-size=10M +quarkus.log.file.rotation.max-backup-index=3 + +# OpenAPI/Swagger Configuration (DEV - toujours activé) +quarkus.swagger-ui.always-include=true +quarkus.swagger-ui.path=/swagger-ui +quarkus.swagger-ui.enable=true + +# Dev Services (activé en DEV) +quarkus.devservices.enabled=false + +# Security Configuration (DEV - plus permissif) +quarkus.security.jaxrs.deny-unannotated-endpoints=false + +# Hot Reload +quarkus.live-reload.instrumentation=true diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..df77357 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,113 @@ +# ============================================================================ +# Lions User Manager - Server Implementation Configuration - PRODUCTION +# ============================================================================ + +# HTTP Configuration +quarkus.http.port=8081 +quarkus.http.host=0.0.0.0 +quarkus.http.cors=true +quarkus.http.cors.origins=https://btpxpress.lions.dev,https://admin.lions.dev +quarkus.http.cors.methods=GET,POST,PUT,DELETE,PATCH,OPTIONS +quarkus.http.cors.headers=* + +# Keycloak OIDC Configuration (PROD) +quarkus.oidc.auth-server-url=https://security.lions.dev/realms/master +quarkus.oidc.client-id=lions-user-manager +quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET} +quarkus.oidc.tls.verification=required +quarkus.oidc.application-type=service + +# Keycloak Admin Client Configuration (PROD) +lions.keycloak.server-url=https://security.lions.dev +lions.keycloak.admin-realm=master +lions.keycloak.admin-client-id=admin-cli +lions.keycloak.admin-username=${KEYCLOAK_ADMIN_USERNAME} +lions.keycloak.admin-password=${KEYCLOAK_ADMIN_PASSWORD} +lions.keycloak.connection-pool-size=20 +lions.keycloak.timeout-seconds=60 + +# Realms autorisés (PROD) +lions.keycloak.authorized-realms=btpxpress,lions-realm + +# Circuit Breaker Configuration (PROD - strict) +quarkus.smallrye-fault-tolerance.enabled=true + +# Retry Configuration (PROD) +lions.keycloak.retry.max-attempts=5 +lions.keycloak.retry.delay-seconds=3 + +# Audit Configuration (PROD) +lions.audit.enabled=true +lions.audit.log-to-database=true +lions.audit.log-to-file=true +lions.audit.retention-days=365 + +# Database Configuration (PROD - obligatoire pour audit) +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=${DB_USERNAME:audit_user} +quarkus.datasource.password=${DB_PASSWORD} +quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:lions-db.lions.svc.cluster.local}:${DB_PORT:5432}/${DB_NAME:lions_audit} +quarkus.datasource.jdbc.max-size=20 +quarkus.datasource.jdbc.min-size=5 +quarkus.hibernate-orm.database.generation=none +quarkus.flyway.migrate-at-start=true +quarkus.flyway.baseline-on-migrate=true +quarkus.flyway.baseline-version=1.0.0 + +# Logging Configuration (PROD) +quarkus.log.level=INFO +quarkus.log.category."dev.lions.user.manager".level=INFO +quarkus.log.category."org.keycloak".level=WARN +quarkus.log.category."io.quarkus".level=WARN + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n +quarkus.log.console.json=true + +# File Logging pour Audit (PROD) +quarkus.log.file.enable=true +quarkus.log.file.path=/var/log/lions/lions-user-manager.log +quarkus.log.file.rotation.max-file-size=50M +quarkus.log.file.rotation.max-backup-index=30 +quarkus.log.file.rotation.rotate-on-boot=false + +# OpenAPI/Swagger Configuration (PROD - désactivé par défaut) +quarkus.swagger-ui.always-include=false +quarkus.swagger-ui.path=/swagger-ui +quarkus.swagger-ui.enable=false + +# Dev Services (désactivé en PROD) +quarkus.devservices.enabled=false + +# Security Configuration (PROD - strict) +quarkus.security.jaxrs.deny-unannotated-endpoints=true + +# Health Check Configuration (PROD) +quarkus.smallrye-health.root-path=/health +quarkus.smallrye-health.liveness-path=/health/live +quarkus.smallrye-health.readiness-path=/health/ready + +# Metrics Configuration (PROD) +quarkus.micrometer.enabled=true +quarkus.micrometer.export.prometheus.enabled=true +quarkus.micrometer.export.prometheus.path=/metrics + +# Jackson Configuration (PROD) +quarkus.jackson.fail-on-unknown-properties=false +quarkus.jackson.write-dates-as-timestamps=false +quarkus.jackson.serialization-inclusion=non_null + +# Performance tuning (PROD) +quarkus.thread-pool.core-threads=2 +quarkus.thread-pool.max-threads=16 +quarkus.thread-pool.queue-size=100 + +# SSL/TLS Configuration (PROD) +quarkus.http.ssl.certificate.key-store-file=${SSL_KEYSTORE_FILE:/etc/ssl/keystore.p12} +quarkus.http.ssl.certificate.key-store-password=${SSL_KEYSTORE_PASSWORD} +quarkus.http.ssl.certificate.key-store-file-type=PKCS12 + +# Monitoring & Observability +quarkus.log.handler.gelf.enabled=false +quarkus.log.handler.gelf.host=${GRAYLOG_HOST:logs.lions.dev} +quarkus.log.handler.gelf.port=${GRAYLOG_PORT:12201} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..0cfcd12 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,100 @@ +# ============================================================================ +# Lions User Manager - Server Implementation Configuration +# ============================================================================ + +# Application Info +quarkus.application.name=lions-user-manager-server +quarkus.application.version=1.0.0 + +# HTTP Configuration +quarkus.http.port=8081 +quarkus.http.host=0.0.0.0 +quarkus.http.cors=true +quarkus.http.cors.origins=* +quarkus.http.cors.methods=GET,POST,PUT,DELETE,PATCH,OPTIONS +quarkus.http.cors.headers=* + +# Keycloak OIDC Configuration +quarkus.oidc.auth-server-url=https://security.lions.dev/realms/master +quarkus.oidc.client-id=lions-user-manager +quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET:your-client-secret} +quarkus.oidc.tls.verification=none +quarkus.oidc.application-type=service + +# Keycloak Admin Client Configuration +lions.keycloak.server-url=https://security.lions.dev +lions.keycloak.admin-realm=master +lions.keycloak.admin-client-id=admin-cli +lions.keycloak.admin-username=${KEYCLOAK_ADMIN_USERNAME:admin} +lions.keycloak.admin-password=${KEYCLOAK_ADMIN_PASSWORD:admin} +lions.keycloak.connection-pool-size=10 +lions.keycloak.timeout-seconds=30 + +# Realms autorisés (séparés par virgule) +lions.keycloak.authorized-realms=btpxpress,master,lions-realm + +# Circuit Breaker Configuration +quarkus.smallrye-fault-tolerance.enabled=true + +# Retry Configuration (pour appels Keycloak) +lions.keycloak.retry.max-attempts=3 +lions.keycloak.retry.delay-seconds=2 + +# Audit Configuration +lions.audit.enabled=true +lions.audit.log-to-database=false +lions.audit.log-to-file=true +lions.audit.retention-days=90 + +# Database Configuration (optionnel - pour logs d'audit) +# Décommenter si vous voulez persister les logs d'audit en DB +#quarkus.datasource.db-kind=postgresql +#quarkus.datasource.username=${DB_USERNAME:audit_user} +#quarkus.datasource.password=${DB_PASSWORD:audit_pass} +#quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:lions_audit} +#quarkus.hibernate-orm.database.generation=none +#quarkus.flyway.migrate-at-start=true + +# Logging Configuration +quarkus.log.level=INFO +quarkus.log.category."dev.lions.user.manager".level=DEBUG +quarkus.log.category."org.keycloak".level=WARN + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n + +# File Logging pour Audit +quarkus.log.file.enable=true +quarkus.log.file.path=logs/lions-user-manager.log +quarkus.log.file.rotation.max-file-size=10M +quarkus.log.file.rotation.max-backup-index=10 + +# OpenAPI/Swagger Configuration +quarkus.swagger-ui.always-include=true +quarkus.swagger-ui.path=/swagger-ui +mp.openapi.extensions.smallrye.info.title=Lions User Manager API +mp.openapi.extensions.smallrye.info.version=1.0.0 +mp.openapi.extensions.smallrye.info.description=API de gestion centralisée des utilisateurs Keycloak +mp.openapi.extensions.smallrye.info.contact.name=Lions Dev Team +mp.openapi.extensions.smallrye.info.contact.email=contact@lions.dev + +# Health Check Configuration +quarkus.smallrye-health.root-path=/health +quarkus.smallrye-health.liveness-path=/health/live +quarkus.smallrye-health.readiness-path=/health/ready + +# Metrics Configuration +quarkus.micrometer.enabled=true +quarkus.micrometer.export.prometheus.enabled=true +quarkus.micrometer.export.prometheus.path=/metrics + +# Security Configuration +quarkus.security.jaxrs.deny-unannotated-endpoints=false + +# Jackson Configuration +quarkus.jackson.fail-on-unknown-properties=false +quarkus.jackson.write-dates-as-timestamps=false +quarkus.jackson.serialization-inclusion=non_null + +# Dev Services (désactivé en production) +quarkus.devservices.enabled=false diff --git a/target/classes/application-dev.properties b/target/classes/application-dev.properties new file mode 100644 index 0000000..836e8d5 --- /dev/null +++ b/target/classes/application-dev.properties @@ -0,0 +1,82 @@ +# ============================================================================ +# Lions User Manager - Server Implementation Configuration - DEV +# ============================================================================ + +# HTTP Configuration +quarkus.http.port=8081 +quarkus.http.host=localhost +quarkus.http.cors=true +quarkus.http.cors.origins=http://localhost:3000,http://localhost:8080 +quarkus.http.cors.methods=GET,POST,PUT,DELETE,PATCH,OPTIONS +quarkus.http.cors.headers=* + +# Keycloak OIDC Configuration (DEV) +quarkus.oidc.auth-server-url=http://localhost:8180/realms/master +quarkus.oidc.client-id=lions-user-manager +quarkus.oidc.credentials.secret=dev-secret-change-me +quarkus.oidc.tls.verification=none +quarkus.oidc.application-type=service + +# Keycloak Admin Client Configuration (DEV) +lions.keycloak.server-url=http://localhost:8180 +lions.keycloak.admin-realm=master +lions.keycloak.admin-client-id=admin-cli +lions.keycloak.admin-username=admin +lions.keycloak.admin-password=admin +lions.keycloak.connection-pool-size=5 +lions.keycloak.timeout-seconds=30 + +# Realms autorisés (DEV) +lions.keycloak.authorized-realms=btpxpress,master,lions-realm,test-realm + +# Circuit Breaker Configuration (DEV - plus permissif) +quarkus.smallrye-fault-tolerance.enabled=true + +# Retry Configuration (DEV) +lions.keycloak.retry.max-attempts=3 +lions.keycloak.retry.delay-seconds=1 + +# Audit Configuration (DEV) +lions.audit.enabled=true +lions.audit.log-to-database=false +lions.audit.log-to-file=true +lions.audit.retention-days=30 + +# Database Configuration (DEV - optionnel) +# Décommenter pour utiliser une DB locale +#quarkus.datasource.db-kind=postgresql +#quarkus.datasource.username=postgres +#quarkus.datasource.password=postgres +#quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/lions_audit_dev +#quarkus.hibernate-orm.database.generation=update +#quarkus.flyway.migrate-at-start=false + +# Logging Configuration (DEV) +quarkus.log.level=DEBUG +quarkus.log.category."dev.lions.user.manager".level=DEBUG +quarkus.log.category."org.keycloak".level=INFO +quarkus.log.category."io.quarkus".level=INFO + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n +quarkus.log.console.color=true + +# File Logging pour Audit (DEV) +quarkus.log.file.enable=true +quarkus.log.file.path=logs/dev/lions-user-manager.log +quarkus.log.file.rotation.max-file-size=10M +quarkus.log.file.rotation.max-backup-index=3 + +# OpenAPI/Swagger Configuration (DEV - toujours activé) +quarkus.swagger-ui.always-include=true +quarkus.swagger-ui.path=/swagger-ui +quarkus.swagger-ui.enable=true + +# Dev Services (activé en DEV) +quarkus.devservices.enabled=false + +# Security Configuration (DEV - plus permissif) +quarkus.security.jaxrs.deny-unannotated-endpoints=false + +# Hot Reload +quarkus.live-reload.instrumentation=true diff --git a/target/classes/application-prod.properties b/target/classes/application-prod.properties new file mode 100644 index 0000000..df77357 --- /dev/null +++ b/target/classes/application-prod.properties @@ -0,0 +1,113 @@ +# ============================================================================ +# Lions User Manager - Server Implementation Configuration - PRODUCTION +# ============================================================================ + +# HTTP Configuration +quarkus.http.port=8081 +quarkus.http.host=0.0.0.0 +quarkus.http.cors=true +quarkus.http.cors.origins=https://btpxpress.lions.dev,https://admin.lions.dev +quarkus.http.cors.methods=GET,POST,PUT,DELETE,PATCH,OPTIONS +quarkus.http.cors.headers=* + +# Keycloak OIDC Configuration (PROD) +quarkus.oidc.auth-server-url=https://security.lions.dev/realms/master +quarkus.oidc.client-id=lions-user-manager +quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET} +quarkus.oidc.tls.verification=required +quarkus.oidc.application-type=service + +# Keycloak Admin Client Configuration (PROD) +lions.keycloak.server-url=https://security.lions.dev +lions.keycloak.admin-realm=master +lions.keycloak.admin-client-id=admin-cli +lions.keycloak.admin-username=${KEYCLOAK_ADMIN_USERNAME} +lions.keycloak.admin-password=${KEYCLOAK_ADMIN_PASSWORD} +lions.keycloak.connection-pool-size=20 +lions.keycloak.timeout-seconds=60 + +# Realms autorisés (PROD) +lions.keycloak.authorized-realms=btpxpress,lions-realm + +# Circuit Breaker Configuration (PROD - strict) +quarkus.smallrye-fault-tolerance.enabled=true + +# Retry Configuration (PROD) +lions.keycloak.retry.max-attempts=5 +lions.keycloak.retry.delay-seconds=3 + +# Audit Configuration (PROD) +lions.audit.enabled=true +lions.audit.log-to-database=true +lions.audit.log-to-file=true +lions.audit.retention-days=365 + +# Database Configuration (PROD - obligatoire pour audit) +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=${DB_USERNAME:audit_user} +quarkus.datasource.password=${DB_PASSWORD} +quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:lions-db.lions.svc.cluster.local}:${DB_PORT:5432}/${DB_NAME:lions_audit} +quarkus.datasource.jdbc.max-size=20 +quarkus.datasource.jdbc.min-size=5 +quarkus.hibernate-orm.database.generation=none +quarkus.flyway.migrate-at-start=true +quarkus.flyway.baseline-on-migrate=true +quarkus.flyway.baseline-version=1.0.0 + +# Logging Configuration (PROD) +quarkus.log.level=INFO +quarkus.log.category."dev.lions.user.manager".level=INFO +quarkus.log.category."org.keycloak".level=WARN +quarkus.log.category."io.quarkus".level=WARN + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n +quarkus.log.console.json=true + +# File Logging pour Audit (PROD) +quarkus.log.file.enable=true +quarkus.log.file.path=/var/log/lions/lions-user-manager.log +quarkus.log.file.rotation.max-file-size=50M +quarkus.log.file.rotation.max-backup-index=30 +quarkus.log.file.rotation.rotate-on-boot=false + +# OpenAPI/Swagger Configuration (PROD - désactivé par défaut) +quarkus.swagger-ui.always-include=false +quarkus.swagger-ui.path=/swagger-ui +quarkus.swagger-ui.enable=false + +# Dev Services (désactivé en PROD) +quarkus.devservices.enabled=false + +# Security Configuration (PROD - strict) +quarkus.security.jaxrs.deny-unannotated-endpoints=true + +# Health Check Configuration (PROD) +quarkus.smallrye-health.root-path=/health +quarkus.smallrye-health.liveness-path=/health/live +quarkus.smallrye-health.readiness-path=/health/ready + +# Metrics Configuration (PROD) +quarkus.micrometer.enabled=true +quarkus.micrometer.export.prometheus.enabled=true +quarkus.micrometer.export.prometheus.path=/metrics + +# Jackson Configuration (PROD) +quarkus.jackson.fail-on-unknown-properties=false +quarkus.jackson.write-dates-as-timestamps=false +quarkus.jackson.serialization-inclusion=non_null + +# Performance tuning (PROD) +quarkus.thread-pool.core-threads=2 +quarkus.thread-pool.max-threads=16 +quarkus.thread-pool.queue-size=100 + +# SSL/TLS Configuration (PROD) +quarkus.http.ssl.certificate.key-store-file=${SSL_KEYSTORE_FILE:/etc/ssl/keystore.p12} +quarkus.http.ssl.certificate.key-store-password=${SSL_KEYSTORE_PASSWORD} +quarkus.http.ssl.certificate.key-store-file-type=PKCS12 + +# Monitoring & Observability +quarkus.log.handler.gelf.enabled=false +quarkus.log.handler.gelf.host=${GRAYLOG_HOST:logs.lions.dev} +quarkus.log.handler.gelf.port=${GRAYLOG_PORT:12201} diff --git a/target/classes/application.properties b/target/classes/application.properties new file mode 100644 index 0000000..0cfcd12 --- /dev/null +++ b/target/classes/application.properties @@ -0,0 +1,100 @@ +# ============================================================================ +# Lions User Manager - Server Implementation Configuration +# ============================================================================ + +# Application Info +quarkus.application.name=lions-user-manager-server +quarkus.application.version=1.0.0 + +# HTTP Configuration +quarkus.http.port=8081 +quarkus.http.host=0.0.0.0 +quarkus.http.cors=true +quarkus.http.cors.origins=* +quarkus.http.cors.methods=GET,POST,PUT,DELETE,PATCH,OPTIONS +quarkus.http.cors.headers=* + +# Keycloak OIDC Configuration +quarkus.oidc.auth-server-url=https://security.lions.dev/realms/master +quarkus.oidc.client-id=lions-user-manager +quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET:your-client-secret} +quarkus.oidc.tls.verification=none +quarkus.oidc.application-type=service + +# Keycloak Admin Client Configuration +lions.keycloak.server-url=https://security.lions.dev +lions.keycloak.admin-realm=master +lions.keycloak.admin-client-id=admin-cli +lions.keycloak.admin-username=${KEYCLOAK_ADMIN_USERNAME:admin} +lions.keycloak.admin-password=${KEYCLOAK_ADMIN_PASSWORD:admin} +lions.keycloak.connection-pool-size=10 +lions.keycloak.timeout-seconds=30 + +# Realms autorisés (séparés par virgule) +lions.keycloak.authorized-realms=btpxpress,master,lions-realm + +# Circuit Breaker Configuration +quarkus.smallrye-fault-tolerance.enabled=true + +# Retry Configuration (pour appels Keycloak) +lions.keycloak.retry.max-attempts=3 +lions.keycloak.retry.delay-seconds=2 + +# Audit Configuration +lions.audit.enabled=true +lions.audit.log-to-database=false +lions.audit.log-to-file=true +lions.audit.retention-days=90 + +# Database Configuration (optionnel - pour logs d'audit) +# Décommenter si vous voulez persister les logs d'audit en DB +#quarkus.datasource.db-kind=postgresql +#quarkus.datasource.username=${DB_USERNAME:audit_user} +#quarkus.datasource.password=${DB_PASSWORD:audit_pass} +#quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:lions_audit} +#quarkus.hibernate-orm.database.generation=none +#quarkus.flyway.migrate-at-start=true + +# Logging Configuration +quarkus.log.level=INFO +quarkus.log.category."dev.lions.user.manager".level=DEBUG +quarkus.log.category."org.keycloak".level=WARN + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n + +# File Logging pour Audit +quarkus.log.file.enable=true +quarkus.log.file.path=logs/lions-user-manager.log +quarkus.log.file.rotation.max-file-size=10M +quarkus.log.file.rotation.max-backup-index=10 + +# OpenAPI/Swagger Configuration +quarkus.swagger-ui.always-include=true +quarkus.swagger-ui.path=/swagger-ui +mp.openapi.extensions.smallrye.info.title=Lions User Manager API +mp.openapi.extensions.smallrye.info.version=1.0.0 +mp.openapi.extensions.smallrye.info.description=API de gestion centralisée des utilisateurs Keycloak +mp.openapi.extensions.smallrye.info.contact.name=Lions Dev Team +mp.openapi.extensions.smallrye.info.contact.email=contact@lions.dev + +# Health Check Configuration +quarkus.smallrye-health.root-path=/health +quarkus.smallrye-health.liveness-path=/health/live +quarkus.smallrye-health.readiness-path=/health/ready + +# Metrics Configuration +quarkus.micrometer.enabled=true +quarkus.micrometer.export.prometheus.enabled=true +quarkus.micrometer.export.prometheus.path=/metrics + +# Security Configuration +quarkus.security.jaxrs.deny-unannotated-endpoints=false + +# Jackson Configuration +quarkus.jackson.fail-on-unknown-properties=false +quarkus.jackson.write-dates-as-timestamps=false +quarkus.jackson.serialization-inclusion=non_null + +# Dev Services (désactivé en production) +quarkus.devservices.enabled=false diff --git a/target/classes/dev/lions/user/manager/client/KeycloakAdminClient.class b/target/classes/dev/lions/user/manager/client/KeycloakAdminClient.class new file mode 100644 index 0000000..3d9910a Binary files /dev/null and b/target/classes/dev/lions/user/manager/client/KeycloakAdminClient.class differ diff --git a/target/classes/dev/lions/user/manager/client/KeycloakAdminClientImpl.class b/target/classes/dev/lions/user/manager/client/KeycloakAdminClientImpl.class new file mode 100644 index 0000000..7defcdd Binary files /dev/null and b/target/classes/dev/lions/user/manager/client/KeycloakAdminClientImpl.class differ diff --git a/target/classes/dev/lions/user/manager/mapper/RoleMapper.class b/target/classes/dev/lions/user/manager/mapper/RoleMapper.class new file mode 100644 index 0000000..4f334fe Binary files /dev/null and b/target/classes/dev/lions/user/manager/mapper/RoleMapper.class differ diff --git a/target/classes/dev/lions/user/manager/mapper/UserMapper.class b/target/classes/dev/lions/user/manager/mapper/UserMapper.class new file mode 100644 index 0000000..17b6ac8 Binary files /dev/null and b/target/classes/dev/lions/user/manager/mapper/UserMapper.class differ diff --git a/target/classes/dev/lions/user/manager/resource/HealthResourceEndpoint.class b/target/classes/dev/lions/user/manager/resource/HealthResourceEndpoint.class new file mode 100644 index 0000000..8cc2df4 Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/HealthResourceEndpoint.class differ diff --git a/target/classes/dev/lions/user/manager/resource/KeycloakHealthCheck.class b/target/classes/dev/lions/user/manager/resource/KeycloakHealthCheck.class new file mode 100644 index 0000000..80557ca Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/KeycloakHealthCheck.class differ diff --git a/target/classes/dev/lions/user/manager/resource/RoleResource$ErrorResponse.class b/target/classes/dev/lions/user/manager/resource/RoleResource$ErrorResponse.class new file mode 100644 index 0000000..a9907ea Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/RoleResource$ErrorResponse.class differ diff --git a/target/classes/dev/lions/user/manager/resource/RoleResource$RoleAssignmentRequest.class b/target/classes/dev/lions/user/manager/resource/RoleResource$RoleAssignmentRequest.class new file mode 100644 index 0000000..c2473d8 Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/RoleResource$RoleAssignmentRequest.class differ diff --git a/target/classes/dev/lions/user/manager/resource/RoleResource.class b/target/classes/dev/lions/user/manager/resource/RoleResource.class new file mode 100644 index 0000000..95fdf3e Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/RoleResource.class differ diff --git a/target/classes/dev/lions/user/manager/resource/UserResource$ErrorResponse.class b/target/classes/dev/lions/user/manager/resource/UserResource$ErrorResponse.class new file mode 100644 index 0000000..c5bf094 Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/UserResource$ErrorResponse.class differ diff --git a/target/classes/dev/lions/user/manager/resource/UserResource$PasswordResetRequest.class b/target/classes/dev/lions/user/manager/resource/UserResource$PasswordResetRequest.class new file mode 100644 index 0000000..2a0fd43 Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/UserResource$PasswordResetRequest.class differ diff --git a/target/classes/dev/lions/user/manager/resource/UserResource$SessionsRevokedResponse.class b/target/classes/dev/lions/user/manager/resource/UserResource$SessionsRevokedResponse.class new file mode 100644 index 0000000..1598153 Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/UserResource$SessionsRevokedResponse.class differ diff --git a/target/classes/dev/lions/user/manager/resource/UserResource.class b/target/classes/dev/lions/user/manager/resource/UserResource.class new file mode 100644 index 0000000..4f03dea Binary files /dev/null and b/target/classes/dev/lions/user/manager/resource/UserResource.class differ diff --git a/target/classes/dev/lions/user/manager/service/impl/AuditServiceImpl.class b/target/classes/dev/lions/user/manager/service/impl/AuditServiceImpl.class new file mode 100644 index 0000000..221c8a6 Binary files /dev/null and b/target/classes/dev/lions/user/manager/service/impl/AuditServiceImpl.class differ diff --git a/target/classes/dev/lions/user/manager/service/impl/RoleServiceImpl.class b/target/classes/dev/lions/user/manager/service/impl/RoleServiceImpl.class new file mode 100644 index 0000000..7243847 Binary files /dev/null and b/target/classes/dev/lions/user/manager/service/impl/RoleServiceImpl.class differ diff --git a/target/classes/dev/lions/user/manager/service/impl/UserServiceImpl.class b/target/classes/dev/lions/user/manager/service/impl/UserServiceImpl.class new file mode 100644 index 0000000..4d64aff Binary files /dev/null and b/target/classes/dev/lions/user/manager/service/impl/UserServiceImpl.class differ diff --git a/target/lions-user-manager-server-impl-quarkus-1.0.0.jar b/target/lions-user-manager-server-impl-quarkus-1.0.0.jar new file mode 100644 index 0000000..34fc61b Binary files /dev/null and b/target/lions-user-manager-server-impl-quarkus-1.0.0.jar differ diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties new file mode 100644 index 0000000..280c289 --- /dev/null +++ b/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=lions-user-manager-server-impl-quarkus +groupId=dev.lions.user.manager +version=1.0.0 diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..ee7f2e9 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,10 @@ +dev\lions\user\manager\resource\UserResource$ErrorResponse.class +dev\lions\user\manager\service\impl\UserServiceImpl.class +dev\lions\user\manager\client\KeycloakAdminClient.class +dev\lions\user\manager\client\KeycloakAdminClientImpl.class +dev\lions\user\manager\resource\UserResource.class +dev\lions\user\manager\resource\KeycloakHealthCheck.class +dev\lions\user\manager\resource\UserResource$SessionsRevokedResponse.class +dev\lions\user\manager\mapper\UserMapper.class +dev\lions\user\manager\resource\HealthResourceEndpoint.class +dev\lions\user\manager\resource\UserResource$PasswordResetRequest.class diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..1517ef1 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,7 @@ +C:\Users\dadyo\PersonalProjects\lions-workspace\lions-user-manager\lions-user-manager-server-impl-quarkus\src\main\java\dev\lions\user\manager\resource\KeycloakHealthCheck.java +C:\Users\dadyo\PersonalProjects\lions-workspace\lions-user-manager\lions-user-manager-server-impl-quarkus\src\main\java\dev\lions\user\manager\service\impl\UserServiceImpl.java +C:\Users\dadyo\PersonalProjects\lions-workspace\lions-user-manager\lions-user-manager-server-impl-quarkus\src\main\java\dev\lions\user\manager\resource\HealthResourceEndpoint.java +C:\Users\dadyo\PersonalProjects\lions-workspace\lions-user-manager\lions-user-manager-server-impl-quarkus\src\main\java\dev\lions\user\manager\resource\UserResource.java +C:\Users\dadyo\PersonalProjects\lions-workspace\lions-user-manager\lions-user-manager-server-impl-quarkus\src\main\java\dev\lions\user\manager\client\KeycloakAdminClient.java +C:\Users\dadyo\PersonalProjects\lions-workspace\lions-user-manager\lions-user-manager-server-impl-quarkus\src\main\java\dev\lions\user\manager\mapper\UserMapper.java +C:\Users\dadyo\PersonalProjects\lions-workspace\lions-user-manager\lions-user-manager-server-impl-quarkus\src\main\java\dev\lions\user\manager\client\KeycloakAdminClientImpl.java