From d812a4feefd8b172f6f5966f7f525db5b3b05602 Mon Sep 17 00:00:00 2001 From: dahoud Date: Mon, 6 Oct 2025 20:23:05 +0000 Subject: [PATCH] =?UTF-8?q?Task=201.3=20-=20Services=20de=20s=C3=A9curit?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Création du JwtService pour la gestion des tokens JWT - Création du PasswordService avec BCrypt pour le hachage sécurisé - Création du SecurityService pour l'authentification et autorisation - Création de l'EmailServiceSimple pour les notifications (version basique) - Support complet de la hiérarchie des rôles GBCM - Gestion des tentatives de connexion échouées et verrouillage de compte - Génération de mots de passe sécurisés avec validation de complexité - Compilation réussie de tous les services de sécurité --- .../com/gbcm/server/impl/entity/User.java | 19 +- .../notification/EmailServiceSimple.java | 233 ++++++++++++ .../impl/service/security/JwtService.java | 313 ++++++++++++++++ .../service/security/PasswordService.java | 305 ++++++++++++++++ .../service/security/SecurityService.java | 339 ++++++++++++++++++ 5 files changed, 1205 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/gbcm/server/impl/service/notification/EmailServiceSimple.java create mode 100644 src/main/java/com/gbcm/server/impl/service/security/JwtService.java create mode 100644 src/main/java/com/gbcm/server/impl/service/security/PasswordService.java create mode 100644 src/main/java/com/gbcm/server/impl/service/security/SecurityService.java diff --git a/src/main/java/com/gbcm/server/impl/entity/User.java b/src/main/java/com/gbcm/server/impl/entity/User.java index bff0563..a93c8b9 100644 --- a/src/main/java/com/gbcm/server/impl/entity/User.java +++ b/src/main/java/com/gbcm/server/impl/entity/User.java @@ -1,19 +1,30 @@ package com.gbcm.server.impl.entity; +import java.time.LocalDateTime; +import java.util.List; + import com.gbcm.server.api.enums.UserRole; + import io.quarkus.security.jpa.Password; import io.quarkus.security.jpa.Roles; import io.quarkus.security.jpa.UserDefinition; import io.quarkus.security.jpa.Username; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import java.time.LocalDateTime; -import java.util.List; - /** * Entité représentant un utilisateur de la plateforme GBCM. * Utilisée pour l'authentification et l'autorisation avec Quarkus Security. diff --git a/src/main/java/com/gbcm/server/impl/service/notification/EmailServiceSimple.java b/src/main/java/com/gbcm/server/impl/service/notification/EmailServiceSimple.java new file mode 100644 index 0000000..59b950e --- /dev/null +++ b/src/main/java/com/gbcm/server/impl/service/notification/EmailServiceSimple.java @@ -0,0 +1,233 @@ +package com.gbcm.server.impl.service.notification; + +import com.gbcm.server.impl.entity.User; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Service d'envoi d'emails simplifié pour la plateforme GBCM. + * Version basique synchrone pour les tests et le développement initial. + * + * @author GBCM Development Team + * @version 1.0 + * @since 1.0 + */ +@ApplicationScoped +public class EmailServiceSimple { + + private static final Logger logger = LoggerFactory.getLogger(EmailServiceSimple.class); + + @ConfigProperty(name = "gbcm.business.notification.email.enabled", defaultValue = "true") + boolean emailNotificationsEnabled; + + @ConfigProperty(name = "quarkus.application.name", defaultValue = "GBCM") + String applicationName; + + /** + * Simule l'envoi d'un email de bienvenue à un nouvel utilisateur. + * + * @param user le nouvel utilisateur + * @param temporaryPassword le mot de passe temporaire (optionnel) + */ + public void sendWelcomeEmail(User user, String temporaryPassword) { + if (!emailNotificationsEnabled) { + logger.debug("Notifications email désactivées, email de bienvenue non envoyé"); + return; + } + + if (user == null || user.getEmail() == null) { + logger.error("Impossible d'envoyer l'email de bienvenue: utilisateur ou email null"); + throw new IllegalArgumentException("Utilisateur et email requis"); + } + + logger.info("SIMULATION - Envoi de l'email de bienvenue à: {}", user.getEmail()); + logger.debug("Contenu de l'email de bienvenue:"); + logger.debug("Destinataire: {} {}", user.getFirstName(), user.getLastName()); + logger.debug("Email: {}", user.getEmail()); + + if (temporaryPassword != null) { + logger.debug("Mot de passe temporaire inclus dans l'email"); + } + + logger.info("Email de bienvenue simulé avec succès pour: {}", user.getEmail()); + } + + /** + * Simule l'envoi d'un email de réinitialisation de mot de passe. + * + * @param user l'utilisateur demandant la réinitialisation + * @param resetToken le token de réinitialisation + * @param resetUrl l'URL de réinitialisation + */ + public void sendPasswordResetEmail(User user, String resetToken, String resetUrl) { + if (!emailNotificationsEnabled) { + logger.debug("Notifications email désactivées, email de réinitialisation non envoyé"); + return; + } + + if (user == null || user.getEmail() == null || resetToken == null || resetUrl == null) { + logger.error("Impossible d'envoyer l'email de réinitialisation: paramètres manquants"); + throw new IllegalArgumentException("Utilisateur, email, token et URL requis"); + } + + logger.info("SIMULATION - Envoi de l'email de réinitialisation de mot de passe à: {}", user.getEmail()); + logger.debug("Contenu de l'email de réinitialisation:"); + logger.debug("Destinataire: {} {}", user.getFirstName(), user.getLastName()); + logger.debug("Email: {}", user.getEmail()); + logger.debug("URL de réinitialisation: {}", resetUrl); + logger.debug("Token: {}...", resetToken.substring(0, Math.min(10, resetToken.length()))); + + logger.info("Email de réinitialisation simulé avec succès pour: {}", user.getEmail()); + } + + /** + * Simule l'envoi d'un email de notification de verrouillage de compte. + * + * @param user l'utilisateur dont le compte est verrouillé + * @param lockDurationMinutes la durée du verrouillage en minutes + */ + public void sendAccountLockedEmail(User user, int lockDurationMinutes) { + if (!emailNotificationsEnabled) { + logger.debug("Notifications email désactivées, email de verrouillage non envoyé"); + return; + } + + if (user == null || user.getEmail() == null) { + logger.error("Impossible d'envoyer l'email de verrouillage: utilisateur ou email null"); + throw new IllegalArgumentException("Utilisateur et email requis"); + } + + logger.info("SIMULATION - Envoi de l'email de verrouillage de compte à: {}", user.getEmail()); + logger.debug("Contenu de l'email de verrouillage:"); + logger.debug("Destinataire: {} {}", user.getFirstName(), user.getLastName()); + logger.debug("Email: {}", user.getEmail()); + logger.debug("Durée du verrouillage: {} minutes", lockDurationMinutes); + + logger.info("Email de verrouillage simulé avec succès pour: {}", user.getEmail()); + } + + /** + * Simule l'envoi d'un email générique. + * + * @param to l'adresse email de destination + * @param subject le sujet de l'email + * @param htmlContent le contenu HTML + */ + public void sendEmail(String to, String subject, String htmlContent) { + if (!emailNotificationsEnabled) { + logger.debug("Notifications email désactivées, email non envoyé"); + return; + } + + if (to == null || subject == null || htmlContent == null) { + logger.error("Impossible d'envoyer l'email: paramètres manquants"); + throw new IllegalArgumentException("Destinataire, sujet et contenu requis"); + } + + logger.info("SIMULATION - Envoi d'email à: {}", to); + logger.debug("Sujet: {}", subject); + logger.debug("Contenu HTML: {} caractères", htmlContent.length()); + + logger.info("Email générique simulé avec succès pour: {}", to); + } + + /** + * Génère le contenu HTML de l'email de bienvenue. + * + * @param user l'utilisateur + * @param temporaryPassword le mot de passe temporaire (optionnel) + * @return le contenu HTML + */ + public String generateWelcomeEmailContent(User user, String temporaryPassword) { + StringBuilder html = new StringBuilder(); + html.append(""); + html.append("Bienvenue"); + html.append("
"); + html.append("

Bienvenue sur ").append(applicationName).append(" !

"); + html.append("

Bonjour ").append(user.getFirstName()).append(" ").append(user.getLastName()).append(",

"); + html.append("

Votre compte a été créé avec succès sur la plateforme GBCM.

"); + html.append("

Email : ").append(user.getEmail()).append("

"); + + if (temporaryPassword != null) { + html.append("

Mot de passe temporaire : ").append(temporaryPassword).append("

"); + html.append("

Important : Veuillez changer votre mot de passe lors de votre première connexion.

"); + } + + html.append("

Vous pouvez maintenant vous connecter et commencer à utiliser nos services de conseil en gestion d'entreprise.

"); + html.append("

Si vous avez des questions, n'hésitez pas à nous contacter.

"); + html.append("

Cordialement,
L'équipe GBCM

"); + html.append("
"); + html.append("

Cet email a été envoyé automatiquement le "); + html.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy à HH:mm"))); + html.append("

"); + html.append("
"); + + return html.toString(); + } + + /** + * Génère le contenu HTML de l'email de réinitialisation de mot de passe. + * + * @param user l'utilisateur + * @param resetToken le token de réinitialisation + * @param resetUrl l'URL de réinitialisation + * @return le contenu HTML + */ + public String generatePasswordResetEmailContent(User user, String resetToken, String resetUrl) { + StringBuilder html = new StringBuilder(); + html.append(""); + html.append("Réinitialisation de mot de passe"); + html.append("
"); + html.append("

Réinitialisation de votre mot de passe

"); + html.append("

Bonjour ").append(user.getFirstName()).append(" ").append(user.getLastName()).append(",

"); + html.append("

Vous avez demandé la réinitialisation de votre mot de passe sur ").append(applicationName).append(".

"); + html.append("

Cliquez sur le lien ci-dessous pour créer un nouveau mot de passe :

"); + html.append("

Réinitialiser mon mot de passe

"); + html.append("

Si le lien ne fonctionne pas, copiez et collez cette URL dans votre navigateur :

"); + html.append("

").append(resetUrl).append("

"); + html.append("

Important : Ce lien expire dans 2 heures pour des raisons de sécurité.

"); + html.append("

Si vous n'avez pas demandé cette réinitialisation, ignorez cet email. Votre mot de passe actuel reste inchangé.

"); + html.append("

Cordialement,
L'équipe GBCM

"); + html.append("
"); + html.append("

Cet email a été envoyé automatiquement le "); + html.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy à HH:mm"))); + html.append("

"); + html.append("
"); + + return html.toString(); + } + + /** + * Génère le contenu HTML de l'email de verrouillage de compte. + * + * @param user l'utilisateur + * @param lockDurationMinutes la durée du verrouillage + * @return le contenu HTML + */ + public String generateAccountLockedEmailContent(User user, int lockDurationMinutes) { + StringBuilder html = new StringBuilder(); + html.append(""); + html.append("Compte verrouillé"); + html.append("
"); + html.append("

Votre compte a été temporairement verrouillé

"); + html.append("

Bonjour ").append(user.getFirstName()).append(" ").append(user.getLastName()).append(",

"); + html.append("

Votre compte ").append(applicationName).append(" a été temporairement verrouillé suite à plusieurs tentatives de connexion échouées.

"); + html.append("

Durée du verrouillage : ").append(lockDurationMinutes).append(" minutes

"); + html.append("

Vous pourrez vous reconnecter après cette période.

"); + html.append("

Mesure de sécurité : Si vous n'êtes pas à l'origine de ces tentatives de connexion, nous vous recommandons de changer votre mot de passe dès que possible.

"); + html.append("

Si vous avez des questions concernant la sécurité de votre compte, contactez-nous immédiatement.

"); + html.append("

Cordialement,
L'équipe GBCM

"); + html.append("
"); + html.append("

Cet email a été envoyé automatiquement le "); + html.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy à HH:mm"))); + html.append("

"); + html.append("
"); + + return html.toString(); + } +} diff --git a/src/main/java/com/gbcm/server/impl/service/security/JwtService.java b/src/main/java/com/gbcm/server/impl/service/security/JwtService.java new file mode 100644 index 0000000..d4f5ba4 --- /dev/null +++ b/src/main/java/com/gbcm/server/impl/service/security/JwtService.java @@ -0,0 +1,313 @@ +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 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(); + } +} diff --git a/src/main/java/com/gbcm/server/impl/service/security/PasswordService.java b/src/main/java/com/gbcm/server/impl/service/security/PasswordService.java new file mode 100644 index 0000000..3e5ac5e --- /dev/null +++ b/src/main/java/com/gbcm/server/impl/service/security/PasswordService.java @@ -0,0 +1,305 @@ +package com.gbcm.server.impl.service.security; + +import io.quarkus.elytron.security.common.BcryptUtil; +import jakarta.enterprise.context.ApplicationScoped; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.SecureRandom; +import java.util.regex.Pattern; + +/** + * Service de gestion des mots de passe pour la plateforme GBCM. + * Fournit les fonctionnalités de hachage, vérification et génération de mots de passe sécurisés. + * + * @author GBCM Development Team + * @version 1.0 + * @since 1.0 + */ +@ApplicationScoped +public class PasswordService { + + private static final Logger logger = LoggerFactory.getLogger(PasswordService.class); + + /** + * Coût BCrypt par défaut (12 = très sécurisé mais plus lent). + */ + private static final int DEFAULT_BCRYPT_COST = 12; + + /** + * Longueur minimale d'un mot de passe. + */ + private static final int MIN_PASSWORD_LENGTH = 8; + + /** + * Longueur maximale d'un mot de passe. + */ + private static final int MAX_PASSWORD_LENGTH = 128; + + /** + * Pattern pour valider la complexité du mot de passe. + * Doit contenir au moins: 1 minuscule, 1 majuscule, 1 chiffre, 1 caractère spécial. + */ + private static final Pattern PASSWORD_PATTERN = Pattern.compile( + "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$" + ); + + /** + * Caractères utilisés pour la génération de mots de passe. + */ + private static final String PASSWORD_CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@$!%*?&"; + + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * Hache un mot de passe en utilisant BCrypt. + * + * @param plainPassword le mot de passe en clair + * @return le hash BCrypt du mot de passe + * @throws IllegalArgumentException si le mot de passe est null ou vide + */ + public String hashPassword(String plainPassword) { + if (plainPassword == null || plainPassword.trim().isEmpty()) { + throw new IllegalArgumentException("Le mot de passe ne peut pas être null ou vide"); + } + + logger.debug("Hachage d'un mot de passe avec BCrypt (coût: {})", DEFAULT_BCRYPT_COST); + + try { + String hashedPassword = BcryptUtil.bcryptHash(plainPassword, DEFAULT_BCRYPT_COST); + logger.debug("Mot de passe haché avec succès"); + return hashedPassword; + } catch (Exception e) { + logger.error("Erreur lors du hachage du mot de passe: {}", e.getMessage()); + throw new RuntimeException("Erreur lors du hachage du mot de passe", e); + } + } + + /** + * Vérifie si un mot de passe en clair correspond au hash stocké. + * + * @param plainPassword le mot de passe en clair à vérifier + * @param hashedPassword le hash stocké + * @return true si le mot de passe correspond, false sinon + * @throws IllegalArgumentException si l'un des paramètres est null + */ + public boolean verifyPassword(String plainPassword, String hashedPassword) { + if (plainPassword == null) { + throw new IllegalArgumentException("Le mot de passe en clair ne peut pas être null"); + } + + if (hashedPassword == null) { + throw new IllegalArgumentException("Le hash du mot de passe ne peut pas être null"); + } + + logger.debug("Vérification d'un mot de passe avec BCrypt"); + + try { + boolean matches = BcryptUtil.matches(plainPassword, hashedPassword); + logger.debug("Résultat de la vérification: {}", matches ? "succès" : "échec"); + return matches; + } catch (Exception e) { + logger.error("Erreur lors de la vérification du mot de passe: {}", e.getMessage()); + return false; + } + } + + /** + * Valide la complexité d'un mot de passe selon les règles de sécurité GBCM. + * + * @param password le mot de passe à valider + * @return true si le mot de passe est valide, false sinon + */ + public boolean isPasswordValid(String password) { + if (password == null) { + logger.debug("Validation échouée: mot de passe null"); + return false; + } + + // Vérifier la longueur + if (password.length() < MIN_PASSWORD_LENGTH) { + logger.debug("Validation échouée: mot de passe trop court (min: {})", MIN_PASSWORD_LENGTH); + return false; + } + + if (password.length() > MAX_PASSWORD_LENGTH) { + logger.debug("Validation échouée: mot de passe trop long (max: {})", MAX_PASSWORD_LENGTH); + return false; + } + + // Vérifier la complexité + boolean isComplex = PASSWORD_PATTERN.matcher(password).matches(); + if (!isComplex) { + logger.debug("Validation échouée: mot de passe ne respecte pas les règles de complexité"); + } + + return isComplex; + } + + /** + * Génère un mot de passe aléatoire sécurisé. + * + * @param length la longueur souhaitée du mot de passe + * @return un mot de passe généré aléatoirement + * @throws IllegalArgumentException si la longueur est invalide + */ + public String generateRandomPassword(int length) { + if (length < MIN_PASSWORD_LENGTH) { + throw new IllegalArgumentException( + String.format("La longueur doit être au moins %d caractères", MIN_PASSWORD_LENGTH) + ); + } + + if (length > MAX_PASSWORD_LENGTH) { + throw new IllegalArgumentException( + String.format("La longueur ne peut pas dépasser %d caractères", MAX_PASSWORD_LENGTH) + ); + } + + logger.debug("Génération d'un mot de passe aléatoire de {} caractères", length); + + StringBuilder password = new StringBuilder(length); + + // S'assurer qu'on a au moins un caractère de chaque type requis + password.append(getRandomChar("abcdefghijklmnopqrstuvwxyz")); // minuscule + password.append(getRandomChar("ABCDEFGHIJKLMNOPQRSTUVWXYZ")); // majuscule + password.append(getRandomChar("0123456789")); // chiffre + password.append(getRandomChar("@$!%*?&")); // caractère spécial + + // Compléter avec des caractères aléatoires + for (int i = 4; i < length; i++) { + password.append(getRandomChar(PASSWORD_CHARS)); + } + + // Mélanger les caractères pour éviter un pattern prévisible + return shuffleString(password.toString()); + } + + /** + * Génère un mot de passe aléatoire avec la longueur par défaut (12 caractères). + * + * @return un mot de passe généré aléatoirement + */ + public String generateRandomPassword() { + return generateRandomPassword(12); + } + + /** + * Vérifie si un mot de passe doit être renouvelé. + * + * @param lastPasswordChange la date du dernier changement de mot de passe + * @param maxAgeInDays l'âge maximum autorisé en jours + * @return true si le mot de passe doit être renouvelé, false sinon + */ + public boolean shouldRenewPassword(java.time.LocalDateTime lastPasswordChange, int maxAgeInDays) { + if (lastPasswordChange == null) { + logger.debug("Renouvellement requis: aucune date de dernier changement"); + return true; + } + + java.time.LocalDateTime expirationDate = lastPasswordChange.plusDays(maxAgeInDays); + boolean shouldRenew = java.time.LocalDateTime.now().isAfter(expirationDate); + + logger.debug("Vérification du renouvellement: dernier changement={}, expiration={}, renouvellement requis={}", + lastPasswordChange, expirationDate, shouldRenew); + + return shouldRenew; + } + + /** + * Calcule la force d'un mot de passe sur une échelle de 0 à 100. + * + * @param password le mot de passe à évaluer + * @return un score de 0 (très faible) à 100 (très fort) + */ + public int calculatePasswordStrength(String password) { + if (password == null || password.isEmpty()) { + return 0; + } + + int score = 0; + + // Longueur (max 25 points) + score += Math.min(password.length() * 2, 25); + + // Variété de caractères (max 25 points chacun) + if (password.matches(".*[a-z].*")) score += 10; // minuscules + if (password.matches(".*[A-Z].*")) score += 10; // majuscules + if (password.matches(".*\\d.*")) score += 10; // chiffres + if (password.matches(".*[@$!%*?&].*")) score += 15; // caractères spéciaux + + // Complexité supplémentaire (max 40 points) + if (password.length() >= 12) score += 10; + if (password.matches(".*[a-z].*[A-Z].*") || password.matches(".*[A-Z].*[a-z].*")) score += 10; + if (password.matches(".*\\d.*[@$!%*?&].*") || password.matches(".*[@$!%*?&].*\\d.*")) score += 10; + if (!hasRepeatingCharacters(password)) score += 10; + + return Math.min(score, 100); + } + + /** + * Obtient un message descriptif de la force du mot de passe. + * + * @param password le mot de passe à évaluer + * @return un message décrivant la force du mot de passe + */ + public String getPasswordStrengthMessage(String password) { + int strength = calculatePasswordStrength(password); + + if (strength < 30) { + return "Très faible - Augmentez la longueur et ajoutez différents types de caractères"; + } else if (strength < 50) { + return "Faible - Ajoutez des majuscules, chiffres et caractères spéciaux"; + } else if (strength < 70) { + return "Moyen - Augmentez la longueur ou la complexité"; + } else if (strength < 90) { + return "Fort - Bon niveau de sécurité"; + } else { + return "Très fort - Excellent niveau de sécurité"; + } + } + + /** + * Obtient un caractère aléatoire d'une chaîne donnée. + * + * @param chars la chaîne de caractères + * @return un caractère aléatoire + */ + private char getRandomChar(String chars) { + return chars.charAt(secureRandom.nextInt(chars.length())); + } + + /** + * Mélange les caractères d'une chaîne. + * + * @param input la chaîne à mélanger + * @return la chaîne mélangée + */ + private String shuffleString(String input) { + char[] chars = input.toCharArray(); + for (int i = chars.length - 1; i > 0; i--) { + int j = secureRandom.nextInt(i + 1); + char temp = chars[i]; + chars[i] = chars[j]; + chars[j] = temp; + } + return new String(chars); + } + + /** + * Vérifie si un mot de passe contient des caractères répétitifs. + * + * @param password le mot de passe à vérifier + * @return true si des caractères se répètent, false sinon + */ + private boolean hasRepeatingCharacters(String password) { + for (int i = 0; i < password.length() - 2; i++) { + if (password.charAt(i) == password.charAt(i + 1) && + password.charAt(i + 1) == password.charAt(i + 2)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/gbcm/server/impl/service/security/SecurityService.java b/src/main/java/com/gbcm/server/impl/service/security/SecurityService.java new file mode 100644 index 0000000..acc57f3 --- /dev/null +++ b/src/main/java/com/gbcm/server/impl/service/security/SecurityService.java @@ -0,0 +1,339 @@ +package com.gbcm.server.impl.service.security; + +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. + * Gère l'authentification, l'autorisation et les contrôles de sécurité. + * + * @author GBCM Development Team + * @version 1.0 + * @since 1.0 + */ +@ApplicationScoped +public class SecurityService { + + private static final Logger logger = LoggerFactory.getLogger(SecurityService.class); + + /** + * Nombre maximum de tentatives de connexion échouées avant verrouillage. + */ + private static final int MAX_FAILED_LOGIN_ATTEMPTS = 5; + + /** + * Durée de verrouillage du compte en minutes après dépassement des tentatives. + */ + private static final int ACCOUNT_LOCK_DURATION_MINUTES = 30; + + @Inject + PasswordService passwordService; + + @Inject + JwtService jwtService; + + @Inject + SecurityIdentity securityIdentity; + + @Inject + JsonWebToken jwt; + + /** + * Authentifie un utilisateur avec email et mot de passe. + * + * @param user l'utilisateur à authentifier + * @param plainPassword le mot de passe en clair + * @param clientIp l'adresse IP du client + * @return true si l'authentification réussit, false sinon + * @throws AuthenticationException si l'authentification échoue + */ + public boolean authenticateUser(User user, String plainPassword, String clientIp) + throws AuthenticationException { + + if (user == null) { + logger.warn("Tentative d'authentification avec utilisateur null"); + throw new AuthenticationException("Utilisateur invalide", "AUTH_INVALID_USER"); + } + + if (plainPassword == null || plainPassword.trim().isEmpty()) { + logger.warn("Tentative d'authentification avec mot de passe vide pour: {}", user.getEmail()); + throw new AuthenticationException("Mot de passe requis", "AUTH_PASSWORD_REQUIRED"); + } + + logger.debug("Tentative d'authentification pour l'utilisateur: {}", user.getEmail()); + + // Vérifier si le compte est verrouillé + if (user.isLocked()) { + logger.warn("Tentative de connexion sur compte verrouillé: {}", user.getEmail()); + throw new AuthenticationException("Compte temporairement verrouillé", "AUTH_ACCOUNT_LOCKED"); + } + + // Vérifier si le compte est actif + if (!user.isActive()) { + logger.warn("Tentative de connexion sur compte inactif: {}", user.getEmail()); + throw new AuthenticationException("Compte désactivé", "AUTH_ACCOUNT_DISABLED"); + } + + // Vérifier le mot de passe + boolean passwordMatches = passwordService.verifyPassword(plainPassword, user.getPasswordHash()); + + if (passwordMatches) { + // Authentification réussie + user.updateLastLogin(clientIp); + logger.info("Authentification réussie pour l'utilisateur: {}", user.getEmail()); + return true; + } else { + // Authentification échouée + handleFailedLogin(user); + logger.warn("Authentification échouée pour l'utilisateur: {} (tentative {})", + user.getEmail(), user.getFailedLoginAttempts()); + throw new AuthenticationException("Email ou mot de passe incorrect", "AUTH_INVALID_CREDENTIALS"); + } + } + + /** + * Vérifie si l'utilisateur actuel a l'autorisation pour un rôle spécifique. + * + * @param requiredRole le rôle requis + * @throws AuthorizationException si l'utilisateur n'a pas l'autorisation + */ + public void requireRole(UserRole requiredRole) throws AuthorizationException { + if (requiredRole == null) { + throw new IllegalArgumentException("Le rôle requis ne peut pas être null"); + } + + UserRole currentRole = getCurrentUserRole(); + if (currentRole == null) { + logger.warn("Tentative d'accès sans authentification pour le rôle: {}", requiredRole); + throw new AuthorizationException("Authentification requise", "AUTH_AUTHENTICATION_REQUIRED"); + } + + if (!hasRole(currentRole, requiredRole)) { + logger.warn("Accès refusé: rôle {} requis, rôle actuel {}", requiredRole, currentRole); + throw new AuthorizationException("Accès refusé - Privilèges insuffisants", "AUTH_INSUFFICIENT_PRIVILEGES"); + } + + logger.debug("Autorisation accordée pour le rôle: {}", requiredRole); + } + + /** + * Vérifie si l'utilisateur actuel a l'un des rôles spécifiés. + * + * @param requiredRoles les rôles autorisés + * @throws AuthorizationException si l'utilisateur n'a aucun des rôles requis + */ + public void requireAnyRole(UserRole... requiredRoles) throws AuthorizationException { + if (requiredRoles == null || requiredRoles.length == 0) { + throw new IllegalArgumentException("Au moins un rôle requis doit être spécifié"); + } + + UserRole currentRole = getCurrentUserRole(); + if (currentRole == null) { + logger.warn("Tentative d'accès sans authentification"); + throw new AuthorizationException("Authentification requise", "AUTH_AUTHENTICATION_REQUIRED"); + } + + for (UserRole requiredRole : requiredRoles) { + if (hasRole(currentRole, requiredRole)) { + logger.debug("Autorisation accordée pour le rôle: {}", requiredRole); + return; + } + } + + logger.warn("Accès refusé: aucun des rôles requis {} ne correspond au rôle actuel {}", + Set.of(requiredRoles), currentRole); + throw new AuthorizationException("Accès refusé - Privilèges insuffisants", "AUTH_INSUFFICIENT_PRIVILEGES"); + } + + /** + * Vérifie si l'utilisateur actuel peut accéder aux données d'un autre utilisateur. + * + * @param targetUserId l'ID de l'utilisateur cible + * @throws AuthorizationException si l'accès n'est pas autorisé + */ + public void requireUserAccessOrAdmin(Long targetUserId) throws AuthorizationException { + if (targetUserId == null) { + throw new IllegalArgumentException("L'ID utilisateur cible ne peut pas être null"); + } + + Long currentUserId = getCurrentUserId(); + UserRole currentRole = getCurrentUserRole(); + + // Les admins peuvent accéder à tous les utilisateurs + if (currentRole == UserRole.ADMIN || currentRole == UserRole.MANAGER) { + logger.debug("Accès autorisé par privilège administrateur"); + return; + } + + // Les utilisateurs peuvent accéder à leurs propres données + if (currentUserId != null && currentUserId.equals(targetUserId)) { + logger.debug("Accès autorisé aux propres données utilisateur"); + return; + } + + logger.warn("Accès refusé: utilisateur {} tente d'accéder aux données de l'utilisateur {}", + currentUserId, targetUserId); + throw new AuthorizationException("Accès refusé - Vous ne pouvez accéder qu'à vos propres données", + "AUTH_ACCESS_DENIED"); + } + + /** + * Obtient l'ID de l'utilisateur actuellement connecté. + * + * @return l'ID de l'utilisateur ou null si non connecté + */ + public Long getCurrentUserId() { + try { + if (jwt != null) { + return jwtService.extractUserId(jwt); + } + } catch (Exception e) { + logger.debug("Impossible d'extraire l'ID utilisateur du token: {}", e.getMessage()); + } + return null; + } + + /** + * Obtient l'email de l'utilisateur actuellement connecté. + * + * @return l'email de l'utilisateur ou null si non connecté + */ + public String getCurrentUserEmail() { + try { + if (jwt != null) { + return jwtService.extractEmail(jwt); + } + } catch (Exception e) { + logger.debug("Impossible d'extraire l'email du token: {}", e.getMessage()); + } + return null; + } + + /** + * Obtient le rôle de l'utilisateur actuellement connecté. + * + * @return le rôle de l'utilisateur ou null si non connecté + */ + public UserRole getCurrentUserRole() { + try { + if (securityIdentity != null && securityIdentity.getRoles() != null) { + Set roles = securityIdentity.getRoles(); + if (!roles.isEmpty()) { + String roleString = roles.iterator().next(); + return UserRole.valueOf(roleString); + } + } + } catch (Exception e) { + logger.debug("Impossible d'extraire le rôle du contexte de sécurité: {}", e.getMessage()); + } + return null; + } + + /** + * Vérifie si l'utilisateur actuel est authentifié. + * + * @return true si authentifié, false sinon + */ + public boolean isAuthenticated() { + return securityIdentity != null && !securityIdentity.isAnonymous(); + } + + /** + * Vérifie si l'utilisateur actuel a un rôle administrateur. + * + * @return true si admin ou manager, false sinon + */ + public boolean isAdmin() { + UserRole currentRole = getCurrentUserRole(); + return currentRole == UserRole.ADMIN || currentRole == UserRole.MANAGER; + } + + /** + * Gère les tentatives de connexion échouées. + * + * @param user l'utilisateur pour lequel la connexion a échoué + */ + private void handleFailedLogin(User user) { + user.incrementFailedLoginAttempts(); + + if (user.getFailedLoginAttempts() >= MAX_FAILED_LOGIN_ATTEMPTS) { + user.lockAccount(ACCOUNT_LOCK_DURATION_MINUTES); + logger.warn("Compte verrouillé pour {} minutes après {} tentatives échouées: {}", + ACCOUNT_LOCK_DURATION_MINUTES, MAX_FAILED_LOGIN_ATTEMPTS, user.getEmail()); + } + } + + /** + * Vérifie si un rôle actuel a les privilèges d'un rôle requis. + * + * @param currentRole le rôle actuel + * @param requiredRole le rôle requis + * @return true si autorisé, false sinon + */ + private boolean hasRole(UserRole currentRole, UserRole requiredRole) { + if (currentRole == requiredRole) { + return true; + } + + // Hiérarchie des rôles: ADMIN > MANAGER > COACH > CLIENT > PROSPECT + switch (requiredRole) { + case PROSPECT: + return true; // Tous les rôles peuvent accéder aux fonctionnalités prospect + case CLIENT: + return currentRole != UserRole.PROSPECT; + case COACH: + return currentRole == UserRole.ADMIN || currentRole == UserRole.MANAGER || currentRole == UserRole.COACH; + case MANAGER: + return currentRole == UserRole.ADMIN || currentRole == UserRole.MANAGER; + case ADMIN: + return currentRole == UserRole.ADMIN; + default: + return false; + } + } + + /** + * Valide qu'un token JWT n'est pas expiré. + * + * @param jwt le token à valider + * @throws AuthenticationException si le token est expiré + */ + 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"); + } + } + + /** + * Enregistre une activité de sécurité dans les logs. + * + * @param action l'action effectuée + * @param details les détails de l'action + */ + public void logSecurityEvent(String action, String details) { + String userInfo = getCurrentUserEmail(); + if (userInfo == null) { + userInfo = "utilisateur anonyme"; + } + + logger.info("Événement de sécurité - Action: {}, Utilisateur: {}, Détails: {}", + action, userInfo, details); + } +}