MAJOR: Complete OIDC migration - Remove JWT custom auth, integrate Keycloak OIDC

- Remove quarkus-smallrye-jwt extension
- Delete JwtService, AuthServiceImpl, AuthService interface
- Replace AuthResource with OIDC-based implementation
- Update SecurityService to use SecurityIdentity instead of JWT
- Configure OIDC with Keycloak (bearer-only backend)
- Add PostgreSQL configuration
- Update entity relationships and database migrations
- Remove JWT-related tests and configurations

BREAKING CHANGE: Authentication now requires Keycloak OIDC tokens
This commit is contained in:
dahoud
2025-10-07 20:20:23 +00:00
parent e206f3f288
commit a5206eb7d9
16 changed files with 352 additions and 1255 deletions

View File

@@ -85,7 +85,7 @@
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<!-- Validation -->

View File

@@ -177,6 +177,18 @@ public class Client extends BaseEntity {
@Size(max = 100, message = "La source d'acquisition ne peut pas dépasser 100 caractères")
private String acquisitionSource;
/**
* Date de début de service.
*/
@Column(name = "service_start_date")
private LocalDate serviceStartDate;
/**
* Date de fin de service.
*/
@Column(name = "service_end_date")
private LocalDate serviceEndDate;
/**
* Constructeur par défaut.
*/
@@ -492,6 +504,22 @@ public class Client extends BaseEntity {
this.acquisitionSource = acquisitionSource;
}
public LocalDate getServiceStartDate() {
return serviceStartDate;
}
public void setServiceStartDate(LocalDate serviceStartDate) {
this.serviceStartDate = serviceStartDate;
}
public LocalDate getServiceEndDate() {
return serviceEndDate;
}
public void setServiceEndDate(LocalDate serviceEndDate) {
this.serviceEndDate = serviceEndDate;
}
@Override
public String toString() {
return "Client{" +
@@ -523,6 +551,11 @@ public class Client extends BaseEntity {
*/
INACTIVE,
/**
* Client suspendu.
*/
SUSPENDED,
/**
* Ancien client.
*/

View File

@@ -454,6 +454,22 @@ public class Coach extends BaseEntity {
this.timezone = timezone;
}
public String getTimeZone() {
return timezone;
}
public void setTimeZone(String timeZone) {
this.timezone = timeZone;
}
public String getLanguagesSpoken() {
return languages;
}
public void setLanguagesSpoken(String languagesSpoken) {
this.languages = languagesSpoken;
}
public LocalDate getStartDate() {
return startDate;
}
@@ -542,6 +558,11 @@ public class Coach extends BaseEntity {
*/
ON_LEAVE,
/**
* Coach suspendu.
*/
SUSPENDED,
/**
* Coach dont le contrat est terminé.
*/

View File

@@ -1,114 +1,78 @@
package com.gbcm.server.impl.resource;
import java.util.HashMap;
import java.util.Map;
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gbcm.server.api.dto.auth.LoginRequestDTO;
import com.gbcm.server.api.dto.auth.LoginResponseDTO;
import com.gbcm.server.api.dto.user.UserDTO;
import com.gbcm.server.api.exceptions.AuthenticationException;
import com.gbcm.server.api.exceptions.GBCMException;
import com.gbcm.server.api.interfaces.AuthService;
import io.quarkus.oidc.OidcSession;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
/**
* Contrôleur REST pour les services d'authentification de la plateforme GBCM.
* Expose tous les endpoints d'authentification, autorisation et gestion des tokens.
* Contrôleur REST pour l'authentification OIDC avec Keycloak.
* Remplace l'ancien système JWT custom par une intégration Keycloak complète.
*
* @author GBCM Development Team
* @version 1.0
* @version 2.0 - OIDC Keycloak
* @since 1.0
*/
@Path("/api/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Authentication", description = "Services d'authentification et autorisation GBCM")
@Tag(name = "Authentication", description = "Services d'authentification OIDC Keycloak pour GBCM")
public class AuthResource {
private static final Logger logger = LoggerFactory.getLogger(AuthResource.class);
@Inject
AuthService authService;
SecurityIdentity securityIdentity;
@Inject
OidcSession oidcSession;
/**
* Endpoint de connexion utilisateur.
*
* @param loginRequest les informations de connexion
* @return la réponse de connexion avec token et informations utilisateur
* @throws AuthenticationException si l'authentification échoue
* @throws GBCMException si une erreur interne survient
* Endpoint d'information sur l'authentification.
* Redirige vers Keycloak pour l'authentification.
*/
@POST
@Path("/login")
@GET
@Path("/info")
@Operation(
summary = "Connexion utilisateur",
description = "Authentifie un utilisateur avec email et mot de passe et retourne un token JWT"
summary = "Informations d'authentification",
description = "Retourne les informations sur l'authentification OIDC Keycloak"
)
@APIResponses({
@APIResponse(
responseCode = "200",
description = "Connexion réussie",
content = @Content(schema = @Schema(implementation = LoginResponseDTO.class))
),
@APIResponse(
responseCode = "401",
description = "Identifiants invalides",
content = @Content(schema = @Schema(implementation = String.class))
),
@APIResponse(
responseCode = "400",
description = "Données de requête invalides",
content = @Content(schema = @Schema(implementation = String.class))
),
@APIResponse(
responseCode = "500",
description = "Erreur interne du serveur",
content = @Content(schema = @Schema(implementation = String.class))
)
@APIResponse(responseCode = "200", description = "Informations d'authentification"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response login(
@Parameter(description = "Informations de connexion", required = true)
@Valid LoginRequestDTO loginRequest
) {
public Response getAuthInfo() {
try {
logger.info("Demande de connexion reçue pour: {}", loginRequest.getEmail());
Map<String, Object> authInfo = new HashMap<>();
authInfo.put("authType", "OIDC");
authInfo.put("provider", "Keycloak");
authInfo.put("realm", "gbcm-llc");
authInfo.put("authUrl", "http://localhost:8180/realms/gbcm-llc/protocol/openid-connect/auth");
authInfo.put("tokenUrl", "http://localhost:8180/realms/gbcm-llc/protocol/openid-connect/token");
authInfo.put("logoutUrl", "http://localhost:8180/realms/gbcm-llc/protocol/openid-connect/logout");
authInfo.put("clientId", "gbcm-backend-api");
LoginResponseDTO response = authService.login(loginRequest);
logger.debug("Informations d'authentification OIDC retournées");
return Response.ok(authInfo).build();
logger.info("Connexion réussie pour: {}", loginRequest.getEmail());
return Response.ok(response).build();
} catch (AuthenticationException e) {
logger.warn("Échec d'authentification pour {}: {}", loginRequest.getEmail(), e.getMessage());
return Response.status(Response.Status.UNAUTHORIZED)
.entity("Erreur d'authentification: " + e.getMessage())
.build();
} catch (GBCMException e) {
logger.error("Erreur GBCM lors de la connexion pour {}: {}", loginRequest.getEmail(), e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("Erreur: " + e.getMessage())
.build();
} catch (Exception e) {
logger.error("Erreur inattendue lors de la connexion pour {}: {}", loginRequest.getEmail(), e.getMessage());
logger.error("Erreur lors de la récupération des informations d'authentification: {}", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();
@@ -116,88 +80,87 @@ public class AuthResource {
}
/**
* Endpoint de déconnexion utilisateur.
*
* @param authToken le token d'authentification
* @return confirmation de déconnexion
* Endpoint de validation du token OIDC.
* Retourne les informations de l'utilisateur authentifié.
*/
@GET
@Path("/me")
@Authenticated
@Operation(
summary = "Informations utilisateur",
description = "Retourne les informations de l'utilisateur authentifié via OIDC"
)
@APIResponses({
@APIResponse(responseCode = "200", description = "Informations utilisateur"),
@APIResponse(responseCode = "401", description = "Non authentifié"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response getCurrentUser() {
try {
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("name", securityIdentity.getPrincipal().getName());
userInfo.put("roles", securityIdentity.getRoles());
userInfo.put("authenticated", securityIdentity.isAnonymous() == false);
// Ajouter les claims OIDC si disponibles (filtrer les objets non sérialisables)
if (securityIdentity.getAttributes() != null) {
Map<String, Object> serializableAttributes = new HashMap<>();
securityIdentity.getAttributes().forEach((key, value) -> {
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
serializableAttributes.put(key, value);
}
});
if (!serializableAttributes.isEmpty()) {
userInfo.put("attributes", serializableAttributes);
}
}
logger.debug("Informations utilisateur retournées pour: {}", securityIdentity.getPrincipal().getName());
return Response.ok(userInfo).build();
} catch (Exception e) {
logger.error("Erreur lors de la récupération des informations utilisateur: {}", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();
}
}
/**
* Endpoint de déconnexion OIDC.
* Invalide la session OIDC.
*/
@POST
@Path("/logout")
@Authenticated
@Operation(
summary = "Déconnexion utilisateur",
description = "Invalide le token d'authentification de l'utilisateur"
summary = "Déconnexion OIDC",
description = "Déconnecte l'utilisateur et invalide la session OIDC"
)
@APIResponses({
@APIResponse(responseCode = "200", description = "Déconnexion réussie"),
@APIResponse(responseCode = "401", description = "Token invalide"),
@APIResponse(responseCode = "401", description = "Non authentifié"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response logout(
@Parameter(description = "Token d'authentification", required = true)
@HeaderParam("Authorization") String authToken
) {
public Response logout() {
try {
logger.info("Demande de déconnexion reçue");
String userName = securityIdentity.getPrincipal().getName();
logger.info("Demande de déconnexion OIDC pour: {}", userName);
authService.logout(authToken);
// Invalider la session OIDC
if (oidcSession != null) {
oidcSession.logout().await().indefinitely();
}
logger.info("Déconnexion réussie");
return Response.ok("Déconnexion réussie").build();
Map<String, Object> response = new HashMap<>();
response.put("message", "Déconnexion réussie");
response.put("logoutUrl", "http://localhost:8180/realms/gbcm-llc/protocol/openid-connect/logout");
} catch (AuthenticationException e) {
logger.warn("Échec de déconnexion: {}", e.getMessage());
return Response.status(Response.Status.UNAUTHORIZED)
.entity("Erreur d'authentification: " + e.getMessage())
.build();
} catch (Exception e) {
logger.error("Erreur inattendue lors de la déconnexion: {}", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();
}
}
/**
* Endpoint de rafraîchissement de token.
*
* @param refreshToken le token de rafraîchissement
* @return nouveau token d'accès
*/
@POST
@Path("/refresh")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Operation(
summary = "Rafraîchissement du token",
description = "Génère un nouveau token d'authentification à partir d'un refresh token"
)
@APIResponses({
@APIResponse(
responseCode = "200",
description = "Token rafraîchi",
content = @Content(schema = @Schema(implementation = LoginResponseDTO.class))
),
@APIResponse(responseCode = "401", description = "Token de rafraîchissement invalide"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response refreshToken(
@Parameter(description = "Token de rafraîchissement", required = true)
@FormParam("refreshToken") String refreshToken
) {
try {
logger.info("Demande de rafraîchissement de token reçue");
LoginResponseDTO response = authService.refreshToken(refreshToken);
logger.info("Token rafraîchi avec succès");
logger.info("Déconnexion OIDC réussie pour: {}", userName);
return Response.ok(response).build();
} catch (AuthenticationException e) {
logger.warn("Échec de rafraîchissement de token: {}", e.getMessage());
return Response.status(Response.Status.UNAUTHORIZED)
.entity("Erreur d'authentification: " + e.getMessage())
.build();
} catch (Exception e) {
logger.error("Erreur inattendue lors du rafraîchissement: {}", e.getMessage());
logger.error("Erreur lors de la déconnexion OIDC: {}", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();
@@ -205,189 +168,33 @@ public class AuthResource {
}
/**
* Endpoint de validation de token.
*
* @param authToken le token d'authentification à valider
* @return les informations de l'utilisateur si le token est valide
* Endpoint de test pour les administrateurs.
*/
@GET
@Path("/validate")
@Path("/admin-test")
@RolesAllowed("ADMIN")
@Operation(
summary = "Validation du token",
description = "Vérifie la validité d'un token d'authentification et retourne les informations utilisateur"
summary = "Test administrateur",
description = "Endpoint de test réservé aux administrateurs"
)
@APIResponses({
@APIResponse(
responseCode = "200",
description = "Token valide",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@APIResponse(responseCode = "401", description = "Token invalide ou expiré"),
@APIResponse(responseCode = "200", description = "Accès autorisé"),
@APIResponse(responseCode = "401", description = "Non authentifié"),
@APIResponse(responseCode = "403", description = "Accès refusé"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response validateToken(
@Parameter(description = "Token d'authentification", required = true)
@HeaderParam("Authorization") String authToken
) {
public Response adminTest() {
try {
logger.debug("Demande de validation de token reçue");
Map<String, Object> response = new HashMap<>();
response.put("message", "Accès administrateur autorisé");
response.put("user", securityIdentity.getPrincipal().getName());
response.put("roles", securityIdentity.getRoles());
UserDTO user = authService.validateToken(authToken);
logger.debug("Test administrateur réussi pour: {}", securityIdentity.getPrincipal().getName());
return Response.ok(response).build();
logger.debug("Token validé avec succès pour l'utilisateur: {}", user.getEmail());
return Response.ok(user).build();
} catch (AuthenticationException e) {
logger.warn("Échec de validation de token: {}", e.getMessage());
return Response.status(Response.Status.UNAUTHORIZED)
.entity("Erreur d'authentification: " + e.getMessage())
.build();
} catch (Exception e) {
logger.error("Erreur inattendue lors de la validation: {}", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();
}
}
/**
* Endpoint de demande de réinitialisation de mot de passe.
*
* @param email l'adresse email de l'utilisateur
* @return confirmation d'envoi de l'email
*/
@POST
@Path("/forgot-password")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Operation(
summary = "Mot de passe oublié",
description = "Envoie un email de réinitialisation de mot de passe à l'utilisateur"
)
@APIResponses({
@APIResponse(responseCode = "200", description = "Email envoyé"),
@APIResponse(responseCode = "400", description = "Email invalide"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response forgotPassword(
@Parameter(description = "Adresse email", required = true)
@FormParam("email") String email
) {
try {
logger.info("Demande de réinitialisation de mot de passe pour: {}", email);
authService.forgotPassword(email);
logger.info("Email de réinitialisation envoyé pour: {}", email);
return Response.ok("Email de réinitialisation envoyé").build();
} catch (GBCMException e) {
logger.warn("Erreur lors de la demande de réinitialisation pour {}: {}", email, e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("Erreur: " + e.getMessage())
.build();
} catch (Exception e) {
logger.error("Erreur inattendue lors de la demande de réinitialisation pour {}: {}", email, e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();
}
}
/**
* Endpoint de réinitialisation de mot de passe.
*
* @param resetToken le token de réinitialisation
* @param newPassword le nouveau mot de passe
* @return confirmation de réinitialisation
*/
@POST
@Path("/reset-password")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Operation(
summary = "Réinitialisation du mot de passe",
description = "Réinitialise le mot de passe avec un token de réinitialisation"
)
@APIResponses({
@APIResponse(responseCode = "200", description = "Mot de passe réinitialisé"),
@APIResponse(responseCode = "400", description = "Token invalide ou expiré"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response resetPassword(
@Parameter(description = "Token de réinitialisation", required = true)
@FormParam("resetToken") String resetToken,
@Parameter(description = "Nouveau mot de passe", required = true)
@FormParam("newPassword") String newPassword
) {
try {
logger.info("Demande de réinitialisation de mot de passe avec token");
authService.resetPassword(resetToken, newPassword);
logger.info("Mot de passe réinitialisé avec succès");
return Response.ok("Mot de passe réinitialisé avec succès").build();
} catch (GBCMException e) {
logger.warn("Erreur lors de la réinitialisation: {}", e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("Erreur: " + e.getMessage())
.build();
} catch (Exception e) {
logger.error("Erreur inattendue lors de la réinitialisation: {}", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();
}
}
/**
* Endpoint de changement de mot de passe.
*
* @param authToken le token d'authentification
* @param oldPassword l'ancien mot de passe
* @param newPassword le nouveau mot de passe
* @return confirmation de changement
*/
@PUT
@Path("/change-password")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Operation(
summary = "Changement de mot de passe",
description = "Change le mot de passe d'un utilisateur authentifié"
)
@APIResponses({
@APIResponse(responseCode = "200", description = "Mot de passe changé"),
@APIResponse(responseCode = "401", description = "Non autorisé"),
@APIResponse(responseCode = "400", description = "Ancien mot de passe incorrect"),
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
})
public Response changePassword(
@Parameter(description = "Token d'authentification", required = true)
@HeaderParam("Authorization") String authToken,
@Parameter(description = "Ancien mot de passe", required = true)
@FormParam("oldPassword") String oldPassword,
@Parameter(description = "Nouveau mot de passe", required = true)
@FormParam("newPassword") String newPassword
) {
try {
logger.info("Demande de changement de mot de passe");
authService.changePassword(authToken, oldPassword, newPassword);
logger.info("Mot de passe changé avec succès");
return Response.ok("Mot de passe changé avec succès").build();
} catch (AuthenticationException e) {
logger.warn("Échec de changement de mot de passe: {}", e.getMessage());
return Response.status(Response.Status.UNAUTHORIZED)
.entity("Erreur d'authentification: " + e.getMessage())
.build();
} catch (GBCMException e) {
logger.warn("Erreur lors du changement de mot de passe: {}", e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("Erreur: " + e.getMessage())
.build();
} catch (Exception e) {
logger.error("Erreur inattendue lors du changement de mot de passe: {}", e.getMessage());
logger.error("Erreur lors du test administrateur: {}", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur interne du serveur")
.build();

View File

@@ -1,213 +0,0 @@
package com.gbcm.server.impl.service;
import com.gbcm.server.api.dto.auth.LoginRequestDTO;
import com.gbcm.server.api.dto.auth.LoginResponseDTO;
import com.gbcm.server.api.dto.user.UserDTO;
import com.gbcm.server.api.exceptions.AuthenticationException;
import com.gbcm.server.api.exceptions.GBCMException;
import com.gbcm.server.api.interfaces.AuthService;
import com.gbcm.server.impl.entity.User;
import com.gbcm.server.impl.service.security.JwtService;
import com.gbcm.server.impl.service.security.PasswordService;
import com.gbcm.server.impl.service.security.SecurityService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Implémentation simplifiée du service d'authentification pour la plateforme GBCM.
* Version basique pour les tests et le développement initial.
*
* @author GBCM Development Team
* @version 1.0
* @since 1.0
*/
@ApplicationScoped
public class AuthServiceImpl implements AuthService {
private static final Logger logger = LoggerFactory.getLogger(AuthServiceImpl.class);
@Inject
JwtService jwtService;
@Inject
PasswordService passwordService;
@Inject
SecurityService securityService;
/**
* {@inheritDoc}
*/
@Override
@Transactional
public LoginResponseDTO login(LoginRequestDTO loginRequest) throws AuthenticationException, GBCMException {
// Validation basique
if (loginRequest == null || loginRequest.getEmail() == null || loginRequest.getPassword() == null) {
throw new AuthenticationException("Email et mot de passe requis", "AUTH_INVALID_REQUEST");
}
logger.info("SIMULATION - Tentative de connexion pour: {}", loginRequest.getEmail());
// Rechercher l'utilisateur
User user = User.findByEmail(loginRequest.getEmail().trim().toLowerCase());
if (user == null) {
logger.warn("Utilisateur non trouvé: {}", loginRequest.getEmail());
throw new AuthenticationException("Email ou mot de passe incorrect", "AUTH_INVALID_CREDENTIALS");
}
// Vérifier le mot de passe
if (!passwordService.verifyPassword(loginRequest.getPassword(), user.getPasswordHash())) {
logger.warn("Mot de passe incorrect pour: {}", loginRequest.getEmail());
throw new AuthenticationException("Email ou mot de passe incorrect", "AUTH_INVALID_CREDENTIALS");
}
// Générer le token
String token = jwtService.generateAccessToken(user).getToken();
// Créer la réponse
LoginResponseDTO response = new LoginResponseDTO();
response.setToken(token);
response.setRefreshToken("refresh_" + token.substring(0, 20)); // Token de refresh simulé
// Créer le DTO utilisateur
UserDTO userDTO = new UserDTO();
userDTO.setId(user.getId());
userDTO.setFirstName(user.getFirstName());
userDTO.setLastName(user.getLastName());
userDTO.setEmail(user.getEmail());
userDTO.setRole(user.getRole());
userDTO.setActive(user.isActive());
response.setUser(userDTO);
logger.info("Connexion réussie pour: {}", user.getEmail());
return response;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void logout(String authToken) throws AuthenticationException {
logger.info("SIMULATION - Déconnexion utilisateur");
// Dans une vraie implémentation, on invaliderait le token
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public LoginResponseDTO refreshToken(String refreshToken) throws AuthenticationException {
logger.info("SIMULATION - Rafraîchissement de token");
if (refreshToken == null || refreshToken.trim().isEmpty()) {
throw new AuthenticationException("Refresh token requis", "AUTH_REFRESH_TOKEN_REQUIRED");
}
// Simulation - dans une vraie implémentation, on validerait le refresh token
// et on générerait un nouveau token d'accès
LoginResponseDTO response = new LoginResponseDTO();
response.setToken("new_access_token_" + System.currentTimeMillis());
response.setRefreshToken(refreshToken); // Garder le même refresh token
logger.info("Token rafraîchi avec succès");
return response;
}
/**
* {@inheritDoc}
*/
@Override
public UserDTO validateToken(String authToken) throws AuthenticationException {
logger.info("SIMULATION - Validation de token");
if (authToken == null || authToken.trim().isEmpty()) {
throw new AuthenticationException("Token requis", "AUTH_TOKEN_REQUIRED");
}
// Simulation - retourner un utilisateur fictif
UserDTO userDTO = new UserDTO();
userDTO.setId(1L);
userDTO.setFirstName("Admin");
userDTO.setLastName("GBCM");
userDTO.setEmail("admin@gbcm.com");
userDTO.setRole(com.gbcm.server.api.enums.UserRole.ADMIN);
userDTO.setActive(true);
return userDTO;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void forgotPassword(String email) throws GBCMException {
logger.info("SIMULATION - Demande de réinitialisation pour: {}", email);
if (email == null || email.trim().isEmpty()) {
throw new GBCMException("Email requis", "AUTH_EMAIL_REQUIRED");
}
// Simulation - dans une vraie implémentation, on enverrait un email
logger.info("Email de réinitialisation simulé envoyé à: {}", email);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void resetPassword(String resetToken, String newPassword) throws GBCMException {
logger.info("SIMULATION - Réinitialisation de mot de passe");
if (resetToken == null || resetToken.trim().isEmpty()) {
throw new GBCMException("Token de réinitialisation requis", "AUTH_RESET_TOKEN_REQUIRED");
}
if (newPassword == null || newPassword.trim().isEmpty()) {
throw new GBCMException("Nouveau mot de passe requis", "AUTH_NEW_PASSWORD_REQUIRED");
}
// Validation du mot de passe
if (!passwordService.isPasswordValid(newPassword)) {
throw new GBCMException("Le mot de passe ne respecte pas les critères de sécurité", "AUTH_PASSWORD_INVALID");
}
logger.info("Mot de passe réinitialisé avec succès");
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void changePassword(String authToken, String oldPassword, String newPassword)
throws AuthenticationException, GBCMException {
logger.info("SIMULATION - Changement de mot de passe");
if (authToken == null || authToken.trim().isEmpty()) {
throw new AuthenticationException("Token d'authentification requis", "AUTH_TOKEN_REQUIRED");
}
if (oldPassword == null || oldPassword.trim().isEmpty()) {
throw new GBCMException("Ancien mot de passe requis", "AUTH_OLD_PASSWORD_REQUIRED");
}
if (newPassword == null || newPassword.trim().isEmpty()) {
throw new GBCMException("Nouveau mot de passe requis", "AUTH_NEW_PASSWORD_REQUIRED");
}
// Validation du nouveau mot de passe
if (!passwordService.isPasswordValid(newPassword)) {
throw new GBCMException("Le nouveau mot de passe ne respecte pas les critères de sécurité", "AUTH_PASSWORD_INVALID");
}
logger.info("Mot de passe changé avec succès");
}
}

View File

@@ -230,4 +230,28 @@ public class EmailServiceSimple {
return html.toString();
}
/**
* Simule l'envoi d'un email de notification de changement de mot de passe.
*
* @param user l'utilisateur qui a changé son mot de passe
*/
public void sendPasswordChangeNotification(User user) {
if (!emailNotificationsEnabled) {
logger.debug("Notifications email désactivées, email de changement de mot de passe non envoyé");
return;
}
if (user == null || user.getEmail() == null) {
logger.error("Impossible d'envoyer l'email de changement de mot de passe: utilisateur ou email null");
throw new IllegalArgumentException("Utilisateur requis");
}
logger.info("SIMULATION - Envoi de l'email de changement de mot de passe à: {}", user.getEmail());
logger.debug("Contenu de l'email de changement de mot de passe:");
logger.debug("Destinataire: {} {}", user.getFirstName(), user.getLastName());
logger.debug("Email: {}", user.getEmail());
logger.info("Email de changement de mot de passe simulé avec succès pour: {}", user.getEmail());
}
}

View File

@@ -1,313 +0,0 @@
package com.gbcm.server.impl.service.security;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Set;
import java.util.UUID;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gbcm.server.api.dto.auth.TokenDTO;
import com.gbcm.server.impl.entity.User;
import io.smallrye.jwt.build.Jwt;
import io.smallrye.jwt.build.JwtClaimsBuilder;
import jakarta.enterprise.context.ApplicationScoped;
/**
* Service de gestion des tokens JWT pour l'authentification GBCM.
* Fournit les fonctionnalités de création, validation et rafraîchissement des tokens.
*
* @author GBCM Development Team
* @version 1.0
* @since 1.0
*/
@ApplicationScoped
public class JwtService {
private static final Logger logger = LoggerFactory.getLogger(JwtService.class);
/**
* Durée de vie par défaut d'un token d'accès (1 heure).
*/
private static final Duration DEFAULT_ACCESS_TOKEN_DURATION = Duration.ofHours(1);
/**
* Durée de vie par défaut d'un token de rafraîchissement (7 jours).
*/
private static final Duration DEFAULT_REFRESH_TOKEN_DURATION = Duration.ofDays(7);
@ConfigProperty(name = "smallrye.jwt.new-token.issuer")
String issuer;
@ConfigProperty(name = "smallrye.jwt.new-token.audience")
String audience;
@ConfigProperty(name = "smallrye.jwt.new-token.lifespan", defaultValue = "3600")
Long defaultLifespan;
/**
* Génère un token d'accès JWT pour un utilisateur.
*
* @param user l'utilisateur pour lequel générer le token
* @return le DTO contenant le token et ses métadonnées
* @throws IllegalArgumentException si l'utilisateur est null ou inactif
*/
public TokenDTO generateAccessToken(User user) {
if (user == null) {
throw new IllegalArgumentException("L'utilisateur ne peut pas être null");
}
if (!user.isActive()) {
throw new IllegalArgumentException("L'utilisateur doit être actif pour générer un token");
}
logger.debug("Génération d'un token d'accès pour l'utilisateur: {}", user.getEmail());
Instant now = Instant.now();
Instant expiresAt = now.plus(DEFAULT_ACCESS_TOKEN_DURATION);
String jti = UUID.randomUUID().toString();
String token = Jwt.issuer(issuer)
.audience(audience)
.subject(user.getEmail())
.groups(Set.of(user.getRole().name()))
.claim("userId", user.getId())
.claim("firstName", user.getFirstName())
.claim("lastName", user.getLastName())
.claim("email", user.getEmail())
.claim("role", user.getRole().name())
.claim("iat", now.getEpochSecond())
.claim("exp", expiresAt.getEpochSecond())
.claim("jti", jti)
.sign();
// Générer aussi un refresh token
String refreshToken = generateRefreshToken(user);
TokenDTO tokenDTO = new TokenDTO();
tokenDTO.setToken(token);
tokenDTO.setTokenType("Bearer");
tokenDTO.setExpiresAt(LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC));
tokenDTO.setExpiresIn(DEFAULT_ACCESS_TOKEN_DURATION.toSeconds());
tokenDTO.setRefreshToken(refreshToken);
logger.info("Token d'accès généré avec succès pour l'utilisateur: {}", user.getEmail());
return tokenDTO;
}
/**
* Génère un token de rafraîchissement pour un utilisateur.
*
* @param user l'utilisateur pour lequel générer le refresh token
* @return le refresh token JWT
* @throws IllegalArgumentException si l'utilisateur est null
*/
public String generateRefreshToken(User user) {
if (user == null) {
throw new IllegalArgumentException("L'utilisateur ne peut pas être null");
}
logger.debug("Génération d'un refresh token pour l'utilisateur: {}", user.getEmail());
Instant now = Instant.now();
Instant expiresAt = now.plus(DEFAULT_REFRESH_TOKEN_DURATION);
String jti = UUID.randomUUID().toString();
return Jwt.issuer(issuer)
.audience(audience + "-refresh")
.subject(user.getEmail())
.claim("userId", user.getId())
.claim("tokenType", "refresh")
.claim("iat", now.getEpochSecond())
.claim("exp", expiresAt.getEpochSecond())
.claim("jti", jti)
.sign();
}
/**
* Génère un token de réinitialisation de mot de passe.
*
* @param user l'utilisateur pour lequel générer le token
* @return le token de réinitialisation
* @throws IllegalArgumentException si l'utilisateur est null
*/
public String generatePasswordResetToken(User user) {
if (user == null) {
throw new IllegalArgumentException("L'utilisateur ne peut pas être null");
}
logger.debug("Génération d'un token de réinitialisation pour l'utilisateur: {}", user.getEmail());
Instant now = Instant.now();
Instant expiresAt = now.plus(Duration.ofHours(2)); // Token valide 2 heures
String jti = UUID.randomUUID().toString();
return Jwt.issuer(issuer)
.audience(audience + "-password-reset")
.subject(user.getEmail())
.claim("userId", user.getId())
.claim("tokenType", "password-reset")
.claim("iat", now.getEpochSecond())
.claim("exp", expiresAt.getEpochSecond())
.claim("jti", jti)
.sign();
}
/**
* Valide un token JWT et retourne ses claims.
*
* @param token le token à valider
* @return les claims du token si valide
* @throws SecurityException si le token est invalide
*/
public JsonWebToken validateToken(String token) {
try {
// La validation est automatiquement effectuée par Quarkus JWT
// Cette méthode peut être étendue pour des validations supplémentaires
logger.debug("Validation du token JWT");
return null; // Sera implémenté avec l'injection du JsonWebToken
} catch (Exception e) {
logger.error("Erreur lors de la validation du token: {}", e.getMessage());
throw new SecurityException("Token JWT invalide", e);
}
}
/**
* Extrait l'ID utilisateur d'un token JWT.
*
* @param jwt le token JWT
* @return l'ID de l'utilisateur
* @throws SecurityException si l'ID n'est pas présent
*/
public Long extractUserId(JsonWebToken jwt) {
if (jwt == null) {
throw new SecurityException("Token JWT requis");
}
Object userIdClaim = jwt.getClaim("userId");
if (userIdClaim == null) {
throw new SecurityException("Claim userId manquant dans le token");
}
try {
return Long.valueOf(userIdClaim.toString());
} catch (NumberFormatException e) {
throw new SecurityException("Format invalide pour userId dans le token", e);
}
}
/**
* Extrait l'email d'un token JWT.
*
* @param jwt le token JWT
* @return l'email de l'utilisateur
* @throws SecurityException si l'email n'est pas présent
*/
public String extractEmail(JsonWebToken jwt) {
if (jwt == null) {
throw new SecurityException("Token JWT requis");
}
String email = jwt.getSubject();
if (email == null || email.trim().isEmpty()) {
throw new SecurityException("Email manquant dans le token");
}
return email;
}
/**
* Vérifie si un token est expiré.
*
* @param jwt le token JWT
* @return true si le token est expiré, false sinon
*/
public boolean isTokenExpired(JsonWebToken jwt) {
if (jwt == null) {
return true;
}
Long exp = jwt.getExpirationTime();
if (exp == null) {
return true;
}
return Instant.ofEpochSecond(exp).isBefore(Instant.now());
}
/**
* Vérifie si un token est de type refresh.
*
* @param jwt le token JWT
* @return true si c'est un refresh token, false sinon
*/
public boolean isRefreshToken(JsonWebToken jwt) {
if (jwt == null) {
return false;
}
Object tokenType = jwt.getClaim("tokenType");
return "refresh".equals(tokenType);
}
/**
* Vérifie si un token est de type password reset.
*
* @param jwt le token JWT
* @return true si c'est un token de réinitialisation, false sinon
*/
public boolean isPasswordResetToken(JsonWebToken jwt) {
if (jwt == null) {
return false;
}
Object tokenType = jwt.getClaim("tokenType");
return "password-reset".equals(tokenType);
}
/**
* Crée un token avec des claims personnalisés.
*
* @param user l'utilisateur
* @param duration la durée de validité
* @param additionalClaims les claims supplémentaires
* @return le token JWT
*/
public String createCustomToken(User user, Duration duration, java.util.Map<String, Object> additionalClaims) {
if (user == null) {
throw new IllegalArgumentException("L'utilisateur ne peut pas être null");
}
logger.debug("Création d'un token personnalisé pour l'utilisateur: {}", user.getEmail());
Instant now = Instant.now();
Instant expiresAt = now.plus(duration);
JwtClaimsBuilder builder = Jwt.issuer(issuer)
.audience(audience)
.subject(user.getEmail())
.claim("userId", user.getId())
.claim("email", user.getEmail())
.claim("iat", now.getEpochSecond())
.claim("exp", expiresAt.getEpochSecond())
.claim("jti", UUID.randomUUID().toString());
// Ajouter les claims supplémentaires
if (additionalClaims != null) {
additionalClaims.forEach(builder::claim);
}
return builder.jws()
.keyId("gbcm-key")
.sign();
}
}

View File

@@ -137,6 +137,16 @@ public class PasswordService {
return isComplex;
}
/**
* Alias pour isPasswordValid - utilisé par les tests.
*
* @param password le mot de passe à valider
* @return true si le mot de passe est valide, false sinon
*/
public boolean validatePassword(String password) {
return isPasswordValid(password);
}
/**
* Génère un mot de passe aléatoire sécurisé.
*

View File

@@ -1,19 +1,19 @@
package com.gbcm.server.impl.service.security;
import java.util.Set;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gbcm.server.api.enums.UserRole;
import com.gbcm.server.api.exceptions.AuthenticationException;
import com.gbcm.server.api.exceptions.AuthorizationException;
import com.gbcm.server.impl.entity.User;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.Set;
/**
* Service de sécurité central pour la plateforme GBCM.
@@ -41,8 +41,7 @@ public class SecurityService {
@Inject
PasswordService passwordService;
@Inject
JwtService jwtService;
// JwtService supprimé - utilise OIDC Keycloak maintenant
@Inject
SecurityIdentity securityIdentity;
@@ -190,33 +189,37 @@ public class SecurityService {
}
/**
* Obtient l'ID de l'utilisateur actuellement connecté.
* Obtient l'ID de l'utilisateur actuellement connecté via OIDC.
*
* @return l'ID de l'utilisateur ou null si non connecté
*/
public Long getCurrentUserId() {
try {
if (jwt != null) {
return jwtService.extractUserId(jwt);
if (securityIdentity != null && !securityIdentity.isAnonymous()) {
// Avec OIDC, nous devons chercher l'utilisateur par email/username
String email = securityIdentity.getPrincipal().getName();
User user = User.findByEmail(email);
return user != null ? user.getId() : null;
}
} catch (Exception e) {
logger.debug("Impossible d'extraire l'ID utilisateur du token: {}", e.getMessage());
logger.debug("Impossible d'extraire l'ID utilisateur OIDC: {}", e.getMessage());
}
return null;
}
/**
* Obtient l'email de l'utilisateur actuellement connecté.
* Obtient l'email de l'utilisateur actuellement connecté via OIDC.
*
* @return l'email de l'utilisateur ou null si non connecté
*/
public String getCurrentUserEmail() {
try {
if (jwt != null) {
return jwtService.extractEmail(jwt);
if (securityIdentity != null && !securityIdentity.isAnonymous()) {
// Avec OIDC, le principal name est généralement l'email
return securityIdentity.getPrincipal().getName();
}
} catch (Exception e) {
logger.debug("Impossible d'extraire l'email du token: {}", e.getMessage());
logger.debug("Impossible d'extraire l'email OIDC: {}", e.getMessage());
}
return null;
}
@@ -305,19 +308,17 @@ public class SecurityService {
}
/**
* Valide qu'un token JWT n'est pas expiré.
* Valide qu'un token OIDC est valide.
* Avec OIDC, la validation est automatiquement effectuée par Quarkus.
*
* @param jwt le token à valider
* @throws AuthenticationException si le token est expiré
* @param jwt le token à valider (peut être null avec OIDC)
* @throws AuthenticationException si l'utilisateur n'est pas authentifié
*/
public void validateTokenNotExpired(JsonWebToken jwt) throws AuthenticationException {
if (jwt == null) {
throw new AuthenticationException("Token requis", "AUTH_TOKEN_REQUIRED");
}
if (jwtService.isTokenExpired(jwt)) {
logger.warn("Tentative d'utilisation d'un token expiré");
throw new AuthenticationException("Token expiré", "AUTH_TOKEN_EXPIRED");
// Avec OIDC, nous vérifions plutôt l'identité de sécurité
if (securityIdentity == null || securityIdentity.isAnonymous()) {
logger.warn("Tentative d'accès sans authentification OIDC");
throw new AuthenticationException("Authentification OIDC requise", "AUTH_OIDC_REQUIRED");
}
}

View File

@@ -11,10 +11,7 @@
#quarkus.hibernate-orm.database.generation=validate
#quarkus.flyway.migrate-at-start=true
# Configuration JWT locale
gbcm.security.jwt.secret=local-development-secret-key-change-me
gbcm.security.jwt.access-token.duration=PT2H
gbcm.security.jwt.refresh-token.duration=P1D
# Configuration locale (JWT supprimé - utilise OIDC Keycloak)
# Configuration email locale (pour tests)
quarkus.mailer.mock=true

View File

@@ -29,19 +29,19 @@ quarkus.flyway.baseline-on-migrate=true
# Security Configuration
quarkus.security.auth.enabled=true
# JWT Configuration
mp.jwt.verify.publickey.location=META-INF/resources/publicKey.pem
mp.jwt.verify.issuer=https://gbcm.com
smallrye.jwt.sign.key.location=META-INF/resources/privateKey.pem
smallrye.jwt.new-token.issuer=https://gbcm.com
smallrye.jwt.new-token.audience=gbcm-users
smallrye.jwt.new-token.lifespan=3600
# Keycloak OIDC Configuration
quarkus.oidc.auth-server-url=http://localhost:8180/realms/gbcm-llc
quarkus.oidc.client-id=gbcm-backend-api
quarkus.oidc.credentials.secret=gbcm-backend-secret
quarkus.oidc.tls.verification=none
quarkus.oidc.application-type=service
quarkus.oidc.bearer-only=true
# GBCM Security Configuration
gbcm.security.jwt.access-token.duration=PT1H
gbcm.security.jwt.refresh-token.duration=P7D
gbcm.security.jwt.reset-token.duration=PT2H
gbcm.security.jwt.secret=${JWT_SECRET:gbcm-super-secret-key-change-in-production}
# Mapping des rôles Keycloak vers GBCM
quarkus.oidc.roles.source=accesstoken
quarkus.oidc.token.principal-claim=preferred_username
# GBCM Security Configuration (non-JWT)
gbcm.security.password.bcrypt-cost=12
gbcm.security.password.min-length=8
gbcm.security.password.require-uppercase=true
@@ -53,9 +53,9 @@ gbcm.security.account.lockout-duration=PT30M
gbcm.security.password.max-age=P90D
# OpenAPI Configuration
quarkus.smallrye-openapi.path=/swagger-ui
quarkus.smallrye-openapi.path=/q/openapi
quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/swagger
quarkus.swagger-ui.path=/q/swagger-ui
mp.openapi.extensions.smallrye.info.title=GBCM Server API
mp.openapi.extensions.smallrye.info.version=1.0.0
mp.openapi.extensions.smallrye.info.description=API pour les services GBCM
@@ -92,8 +92,11 @@ quarkus.log.category."org.hibernate.SQL".level=DEBUG
# Development Profile
%dev.quarkus.log.level=DEBUG
%dev.quarkus.hibernate-orm.log.sql=true
%dev.quarkus.datasource.jdbc.url=jdbc:h2:mem:gbcm_server_dev;DB_CLOSE_DELAY=-1
%dev.quarkus.datasource.db-kind=h2
# Configuration PostgreSQL pour GBCM LLC (utilise la base skyfile directement)
%dev.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/skyfile
%dev.quarkus.datasource.db-kind=postgresql
%dev.quarkus.datasource.username=skyfile
%dev.quarkus.datasource.password=skyfile
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.flyway.migrate-at-start=false
%dev.gbcm.security.jwt.secret=dev-secret-key-not-for-production

View File

@@ -60,7 +60,7 @@ CREATE TABLE coaches (
-- Table de liaison pour les types de services offerts par le coach
CREATE TABLE coach_service_types (
coach_id BIGINT NOT NULL,
service_type VARCHAR(30) NOT NULL CHECK (service_type IN ('strategic_workshop', 'one_on_one_coaching', 'on_demand_coaching', 'special_project')),
service_type VARCHAR(30) NOT NULL CHECK (service_type IN ('STRATEGIC_WORKSHOP', 'ONE_ON_ONE_COACHING', 'ON_DEMAND_COACHING', 'SPECIAL_PROJECT')),
PRIMARY KEY (coach_id, service_type),
CONSTRAINT fk_coach_service_types_coach_id FOREIGN KEY (coach_id) REFERENCES coaches(id) ON DELETE CASCADE

View File

@@ -0,0 +1,14 @@
-- Migration V6: Ajout de la colonne status à la table users
-- Auteur: GBCM Development Team
-- Date: 2025-10-07
-- Description: Ajout de la colonne status manquante dans la table users
-- Ajout de la colonne status
ALTER TABLE users ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE';
-- Ajout de la contrainte CHECK pour les valeurs valides
ALTER TABLE users ADD CONSTRAINT chk_users_status
CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING'));
-- Commentaire sur la nouvelle colonne
COMMENT ON COLUMN users.status IS 'Statut de l''utilisateur (ACTIVE, INACTIVE, SUSPENDED, PENDING)';

View File

@@ -1,38 +1,64 @@
-- Données de test pour le développement GBCM
-- =====================================================
-- DONNÉES DE TEST POUR LE DÉVELOPPEMENT GBCM
-- =====================================================
-- Ce fichier est chargé automatiquement en mode développement
-- Mot de passe pour tous les utilisateurs: "password123" (hash BCrypt)
-- Insertion des utilisateurs de test
-- Mot de passe pour tous: "password123" (hash BCrypt)
INSERT INTO users (first_name, last_name, email, password_hash, role, active, deleted, created_by) VALUES
('Admin', 'System', 'admin@gbcm.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'ADMIN', true, false, 'system'),
('John', 'Manager', 'manager@gbcm.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'MANAGER', true, false, 'system'),
('Sarah', 'Coach', 'sarah.coach@gbcm.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'COACH', true, false, 'system'),
('Michael', 'Expert', 'michael.expert@gbcm.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'COACH', true, false, 'system'),
('Emily', 'Johnson', 'emily.johnson@techcorp.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'CLIENT', true, false, 'system'),
('David', 'Smith', 'david.smith@innovate.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'CLIENT', true, false, 'system'),
('Lisa', 'Brown', 'lisa.brown@startup.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'PROSPECT', true, false, 'system'),
('Robert', 'Wilson', 'robert.wilson@enterprise.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G', 'PROSPECT', true, false, 'system');
-- =====================================================
-- 1. INSERTION DES UTILISATEURS DE BASE
-- =====================================================
INSERT INTO users (
first_name, last_name, email, password_hash, role, status,
active, failed_login_attempts, deleted,
created_at, updated_at, created_by
) VALUES
-- Administrateur système
('Admin', 'System', 'admin@gbcm.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'ADMIN', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system'),
-- Insertion des coaches
INSERT INTO coaches (user_id, specialization, bio, years_of_experience, certifications, languages, hourly_rate, status, available_for_booking, working_hours_start, working_hours_end, working_days, timezone, start_date, average_rating, total_ratings, total_sessions, total_revenue, deleted, created_by) VALUES
(3, 'Strategic Planning', 'Expert en planification stratégique avec plus de 10 ans d''expérience dans le conseil aux PME. Spécialisée dans la transformation digitale et l''optimisation des processus.', 10, 'Certified Management Consultant (CMC), PMP, Lean Six Sigma Black Belt', 'English, French, Spanish', 125.00, 'ACTIVE', true, '09:00:00', '17:00:00', 'MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY', 'America/New_York', '2020-01-15', 4.8, 45, 120, 15000.00, false, 'system'),
(4, 'Business Development', 'Coach expérimenté en développement commercial et leadership. Aide les entrepreneurs à développer leurs compétences managériales et à faire croître leur entreprise.', 8, 'Certified Business Coach (CBC), MBA, Dale Carnegie Leadership Training', 'English, French', 100.00, 'ACTIVE', true, '08:00:00', '18:00:00', 'MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY', 'America/New_York', '2021-03-01', 4.9, 38, 95, 9500.00, false, 'system');
-- Manager
('John', 'Manager', 'manager@gbcm.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'MANAGER', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system'),
-- Insertion des types de services pour les coaches
INSERT INTO coach_service_types (coach_id, service_type) VALUES
(1, 'strategic_workshop'),
(1, 'one_on_one_coaching'),
(1, 'special_project'),
(2, 'one_on_one_coaching'),
(2, 'on_demand_coaching'),
(2, 'special_project');
-- Coaches
('Sarah', 'Coach', 'sarah.coach@gbcm.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'COACH', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system'),
-- Insertion des clients
INSERT INTO clients (user_id, company_name, industry, company_size, annual_revenue, address_line1, city, state, postal_code, country, website, status, converted_at, primary_service_type, total_contract_value, relationship_start_date, acquisition_source, deleted, created_by) VALUES
(5, 'TechCorp Solutions', 'Technology', 50, 2500000.00, '123 Tech Street', 'Atlanta', 'GA', '30309', 'USA', 'https://techcorp.com', 'ACTIVE', '2023-06-15 10:30:00', 'strategic_workshop', 4000.00, '2023-06-15', 'Website', false, 'system'),
(6, 'Innovate Inc', 'Consulting', 25, 1200000.00, '456 Innovation Ave', 'Atlanta', 'GA', '30308', 'USA', 'https://innovate.com', 'ACTIVE', '2023-08-20 14:15:00', 'one_on_one_coaching', 2500.00, '2023-08-20', 'Referral', false, 'system');
('Michael', 'Expert', 'michael.expert@gbcm.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'COACH', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system'),
-- Insertion des prospects
INSERT INTO clients (user_id, company_name, industry, company_size, annual_revenue, address_line1, city, state, postal_code, country, website, status, acquisition_source, deleted, created_by) VALUES
(7, 'StartupCo', 'E-commerce', 10, 500000.00, '789 Startup Blvd', 'Atlanta', 'GA', '30307', 'USA', 'https://startupco.com', 'PROSPECT', 'LinkedIn', false, 'system'),
(8, 'Enterprise Corp', 'Manufacturing', 200, 15000000.00, '321 Enterprise Way', 'Atlanta', 'GA', '30306', 'USA', 'https://enterprise.com', 'PROSPECT', 'Trade Show', false, 'system');
-- Clients
('Emily', 'Johnson', 'emily.johnson@techcorp.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'CLIENT', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system'),
('David', 'Smith', 'david.smith@innovate.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'CLIENT', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system'),
-- Prospects
('Lisa', 'Brown', 'lisa.brown@startup.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'PROSPECT', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system'),
('Robert', 'Wilson', 'robert.wilson@enterprise.com',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9P8jW9TjnOvQF9G',
'PROSPECT', 'ACTIVE', true, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system');
-- =====================================================
-- NOTE: DONNÉES COMPLEXES VIA API
-- =====================================================
-- Les coaches, clients et autres entités avec relations
-- seront créés via les endpoints REST pour éviter les
-- problèmes de clés étrangères avec les IDs auto-générés.
--
-- Utilisez les endpoints suivants après le démarrage:
-- POST /api/coaches - Créer des coaches
-- POST /api/clients - Créer des clients
-- POST /api/workshops - Créer des ateliers
-- POST /api/coaching-sessions - Créer des sessions
-- =====================================================

View File

@@ -1,319 +0,0 @@
package com.gbcm.server.impl.service;
import com.gbcm.server.api.dto.auth.LoginRequestDTO;
import com.gbcm.server.api.dto.auth.LoginResponseDTO;
import com.gbcm.server.api.dto.auth.TokenDTO;
import com.gbcm.server.api.dto.user.UserDTO;
import com.gbcm.server.api.enums.UserRole;
import com.gbcm.server.api.exceptions.AuthenticationException;
import com.gbcm.server.api.exceptions.GBCMException;
import com.gbcm.server.impl.entity.User;
import com.gbcm.server.impl.service.security.JwtService;
import com.gbcm.server.impl.service.security.PasswordService;
import com.gbcm.server.impl.service.security.SecurityService;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.mockito.InjectMock;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests unitaires pour AuthServiceImpl.
* Vérifie toutes les méthodes d'authentification avec mocks.
*
* @author GBCM Development Team
* @version 1.0
* @since 1.0
*/
@QuarkusTest
@DisplayName("Tests AuthServiceImpl")
class AuthServiceImplTest {
@Inject
AuthServiceImpl authService;
@InjectMock
JwtService jwtService;
@InjectMock
PasswordService passwordService;
@InjectMock
SecurityService securityService;
private LoginRequestDTO validLoginRequest;
private User mockUser;
private TokenDTO mockToken;
/**
* Configuration initiale avant chaque test.
* Prépare les objets de test et les mocks.
*/
@BeforeEach
void setUp() {
// Préparation des données de test
validLoginRequest = new LoginRequestDTO();
validLoginRequest.setEmail("admin@gbcm.com");
validLoginRequest.setPassword("admin123");
// Mock User
mockUser = new User();
mockUser.setId(1L);
mockUser.setFirstName("Admin");
mockUser.setLastName("GBCM");
mockUser.setEmail("admin@gbcm.com");
mockUser.setPasswordHash("$2a$10$hashedPassword");
mockUser.setRole(UserRole.ADMIN);
mockUser.setActive(true);
// Mock Token
mockToken = new TokenDTO();
mockToken.setToken("mock_jwt_token_12345");
mockToken.setExpiresAt(java.time.LocalDateTime.now().plusHours(1));
// Reset mocks
Mockito.reset(jwtService, passwordService, securityService);
}
/**
* Test d'injection du service.
* Vérifie que le service est correctement injecté.
*/
@Test
@DisplayName("Injection du service AuthServiceImpl")
void testServiceInjection() {
assertThat(authService).isNotNull();
assertThat(jwtService).isNotNull();
assertThat(passwordService).isNotNull();
assertThat(securityService).isNotNull();
}
/**
* Test de connexion avec utilisateur existant.
* Vérifie qu'une exception est levée car User.findByEmail() retourne null en simulation.
*/
@Test
@DisplayName("Connexion avec utilisateur non trouvé (simulation)")
void testLogin_UserNotFoundInSimulation() throws Exception {
// Given - En mode simulation, User.findByEmail() retourne toujours null
when(passwordService.verifyPassword(anyString(), anyString())).thenReturn(true);
when(jwtService.generateAccessToken(any(User.class))).thenReturn(mockToken);
// When & Then - L'utilisateur ne sera pas trouvé en simulation
assertThatThrownBy(() -> authService.login(validLoginRequest))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Email ou mot de passe incorrect");
}
/**
* Test de connexion avec requête null.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Connexion avec requête null")
void testLogin_NullRequest() {
// When & Then
assertThatThrownBy(() -> authService.login(null))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Email et mot de passe requis");
}
/**
* Test de connexion avec email null.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Connexion avec email null")
void testLogin_NullEmail() {
// Given
validLoginRequest.setEmail(null);
// When & Then
assertThatThrownBy(() -> authService.login(validLoginRequest))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Email et mot de passe requis");
}
/**
* Test de connexion avec mot de passe null.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Connexion avec mot de passe null")
void testLogin_NullPassword() {
// Given
validLoginRequest.setPassword(null);
// When & Then
assertThatThrownBy(() -> authService.login(validLoginRequest))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Email et mot de passe requis");
}
/**
* Test de connexion avec utilisateur non trouvé.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Connexion avec utilisateur non trouvé")
void testLogin_UserNotFound() {
// Given - User.findByEmail retournera null (comportement par défaut)
validLoginRequest.setEmail("nonexistent@gbcm.com");
// When & Then
assertThatThrownBy(() -> authService.login(validLoginRequest))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Email ou mot de passe incorrect");
}
/**
* Test de connexion avec mot de passe incorrect.
* En simulation, User.findByEmail() retourne null donc ce test vérifie le comportement attendu.
*/
@Test
@DisplayName("Connexion avec mot de passe incorrect (simulation)")
void testLogin_WrongPassword() throws Exception {
// Given - En simulation, l'utilisateur ne sera pas trouvé
when(passwordService.verifyPassword(anyString(), anyString())).thenReturn(false);
// When & Then - L'exception sera levée car utilisateur non trouvé
assertThatThrownBy(() -> authService.login(validLoginRequest))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Email ou mot de passe incorrect");
// Note: passwordService.verifyPassword ne sera pas appelé car User.findByEmail() retourne null
}
/**
* Test de déconnexion.
* Vérifie que la méthode logout fonctionne sans erreur.
*/
@Test
@DisplayName("Déconnexion utilisateur")
void testLogout() {
// When & Then - Ne doit pas lever d'exception
assertThatCode(() -> authService.logout("valid_token"))
.doesNotThrowAnyException();
}
/**
* Test de rafraîchissement de token réussi.
* Vérifie qu'un nouveau token est généré.
*/
@Test
@DisplayName("Rafraîchissement de token réussi")
void testRefreshToken_Success() throws Exception {
// Given
String refreshToken = "valid_refresh_token";
// When
LoginResponseDTO result = authService.refreshToken(refreshToken);
// Then
assertThat(result).isNotNull();
assertThat(result.getToken()).startsWith("new_access_token_");
assertThat(result.getRefreshToken()).isEqualTo(refreshToken);
}
/**
* Test de rafraîchissement avec token null.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Rafraîchissement avec token null")
void testRefreshToken_NullToken() {
// When & Then
assertThatThrownBy(() -> authService.refreshToken(null))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Refresh token requis");
}
/**
* Test de rafraîchissement avec token vide.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Rafraîchissement avec token vide")
void testRefreshToken_EmptyToken() {
// When & Then
assertThatThrownBy(() -> authService.refreshToken(" "))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Refresh token requis");
}
/**
* Test de validation de token réussie.
* Vérifie qu'un UserDTO est retourné.
*/
@Test
@DisplayName("Validation de token réussie")
void testValidateToken_Success() throws Exception {
// Given
String authToken = "Bearer valid_token";
// When
UserDTO result = authService.validateToken(authToken);
// Then
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getEmail()).isEqualTo("admin@gbcm.com");
assertThat(result.getRole()).isEqualTo(UserRole.ADMIN);
assertThat(result.isActive()).isTrue();
}
/**
* Test de validation avec token null.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Validation avec token null")
void testValidateToken_NullToken() {
// When & Then
assertThatThrownBy(() -> authService.validateToken(null))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Token requis");
}
/**
* Test de validation avec token vide.
* Vérifie qu'une AuthenticationException est levée.
*/
@Test
@DisplayName("Validation avec token vide")
void testValidateToken_EmptyToken() {
// When & Then
assertThatThrownBy(() -> authService.validateToken(" "))
.isInstanceOf(AuthenticationException.class)
.hasMessageContaining("Token requis");
}
/**
* Test de performance pour la connexion.
* Vérifie que la tentative de connexion se fait rapidement même si elle échoue.
*/
@Test
@DisplayName("Performance de la connexion")
void testLogin_Performance() throws Exception {
// Given
when(passwordService.verifyPassword(anyString(), anyString())).thenReturn(true);
when(jwtService.generateAccessToken(any(User.class))).thenReturn(mockToken);
// When & Then - Mesurer le temps même si l'exception est levée
long startTime = System.currentTimeMillis();
try {
authService.login(validLoginRequest);
} catch (AuthenticationException e) {
// Exception attendue en simulation
}
long endTime = System.currentTimeMillis();
assertThat(endTime - startTime).isLessThan(1000L);
}
}

View File

@@ -109,9 +109,10 @@ class EmailServiceSimpleTest {
void testSendPasswordResetEmail_ValidUser() {
// Given
String resetToken = "reset-token-123";
String resetUrl = "https://gbcm.com/reset-password?token=" + resetToken;
// When & Then - Ne doit pas lever d'exception
assertThatCode(() -> emailService.sendPasswordResetEmail(testUser, resetToken))
assertThatCode(() -> emailService.sendPasswordResetEmail(testUser, resetToken, resetUrl))
.doesNotThrowAnyException();
}
@@ -123,11 +124,12 @@ class EmailServiceSimpleTest {
void testSendPasswordResetEmail_NullUser() {
// Given
String resetToken = "reset-token-123";
String resetUrl = "https://gbcm.com/reset-password?token=" + resetToken;
// When & Then
assertThatThrownBy(() -> emailService.sendPasswordResetEmail(null, resetToken))
assertThatThrownBy(() -> emailService.sendPasswordResetEmail(null, resetToken, resetUrl))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Utilisateur et token requis");
.hasMessage("Utilisateur, email, token et URL requis");
}
/**
@@ -136,10 +138,13 @@ class EmailServiceSimpleTest {
@Test
@DisplayName("Envoi d'email de réinitialisation avec token null")
void testSendPasswordResetEmail_NullToken() {
// Given
String resetUrl = "https://gbcm.com/reset-password";
// When & Then
assertThatThrownBy(() -> emailService.sendPasswordResetEmail(testUser, null))
assertThatThrownBy(() -> emailService.sendPasswordResetEmail(testUser, null, resetUrl))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Utilisateur et token requis");
.hasMessage("Utilisateur, email, token et URL requis");
}
/**
@@ -151,11 +156,12 @@ class EmailServiceSimpleTest {
// Given
testUser.setEmail(null);
String resetToken = "reset-token-123";
String resetUrl = "https://gbcm.com/reset-password?token=" + resetToken;
// When & Then
assertThatThrownBy(() -> emailService.sendPasswordResetEmail(testUser, resetToken))
assertThatThrownBy(() -> emailService.sendPasswordResetEmail(testUser, resetToken, resetUrl))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Utilisateur et token requis");
.hasMessage("Utilisateur, email, token et URL requis");
}
/**