Task 1.3 - Services de sécurité

- 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é
This commit is contained in:
dahoud
2025-10-06 20:23:05 +00:00
parent 9d8ce834e8
commit d812a4feef
5 changed files with 1205 additions and 4 deletions

View File

@@ -1,19 +1,30 @@
package com.gbcm.server.impl.entity; package com.gbcm.server.impl.entity;
import java.time.LocalDateTime;
import java.util.List;
import com.gbcm.server.api.enums.UserRole; import com.gbcm.server.api.enums.UserRole;
import io.quarkus.security.jpa.Password; import io.quarkus.security.jpa.Password;
import io.quarkus.security.jpa.Roles; import io.quarkus.security.jpa.Roles;
import io.quarkus.security.jpa.UserDefinition; import io.quarkus.security.jpa.UserDefinition;
import io.quarkus.security.jpa.Username; 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.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
/** /**
* Entité représentant un utilisateur de la plateforme GBCM. * Entité représentant un utilisateur de la plateforme GBCM.
* Utilisée pour l'authentification et l'autorisation avec Quarkus Security. * Utilisée pour l'authentification et l'autorisation avec Quarkus Security.

View File

@@ -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("<!DOCTYPE html>");
html.append("<html><head><meta charset='UTF-8'><title>Bienvenue</title></head><body>");
html.append("<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>");
html.append("<h1 style='color: #2c3e50;'>Bienvenue sur ").append(applicationName).append(" !</h1>");
html.append("<p>Bonjour ").append(user.getFirstName()).append(" ").append(user.getLastName()).append(",</p>");
html.append("<p>Votre compte a été créé avec succès sur la plateforme GBCM.</p>");
html.append("<p><strong>Email :</strong> ").append(user.getEmail()).append("</p>");
if (temporaryPassword != null) {
html.append("<p><strong>Mot de passe temporaire :</strong> ").append(temporaryPassword).append("</p>");
html.append("<p style='color: #e74c3c;'><strong>Important :</strong> Veuillez changer votre mot de passe lors de votre première connexion.</p>");
}
html.append("<p>Vous pouvez maintenant vous connecter et commencer à utiliser nos services de conseil en gestion d'entreprise.</p>");
html.append("<p>Si vous avez des questions, n'hésitez pas à nous contacter.</p>");
html.append("<p>Cordialement,<br>L'équipe GBCM</p>");
html.append("<hr style='margin: 20px 0; border: none; border-top: 1px solid #eee;'>");
html.append("<p style='font-size: 12px; color: #7f8c8d;'>Cet email a été envoyé automatiquement le ");
html.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy à HH:mm")));
html.append("</p>");
html.append("</div></body></html>");
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("<!DOCTYPE html>");
html.append("<html><head><meta charset='UTF-8'><title>Réinitialisation de mot de passe</title></head><body>");
html.append("<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>");
html.append("<h1 style='color: #2c3e50;'>Réinitialisation de votre mot de passe</h1>");
html.append("<p>Bonjour ").append(user.getFirstName()).append(" ").append(user.getLastName()).append(",</p>");
html.append("<p>Vous avez demandé la réinitialisation de votre mot de passe sur ").append(applicationName).append(".</p>");
html.append("<p>Cliquez sur le lien ci-dessous pour créer un nouveau mot de passe :</p>");
html.append("<p><a href='").append(resetUrl).append("' style='background-color: #3498db; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;'>Réinitialiser mon mot de passe</a></p>");
html.append("<p>Si le lien ne fonctionne pas, copiez et collez cette URL dans votre navigateur :</p>");
html.append("<p style='word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 3px;'>").append(resetUrl).append("</p>");
html.append("<p style='color: #e74c3c;'><strong>Important :</strong> Ce lien expire dans 2 heures pour des raisons de sécurité.</p>");
html.append("<p>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email. Votre mot de passe actuel reste inchangé.</p>");
html.append("<p>Cordialement,<br>L'équipe GBCM</p>");
html.append("<hr style='margin: 20px 0; border: none; border-top: 1px solid #eee;'>");
html.append("<p style='font-size: 12px; color: #7f8c8d;'>Cet email a été envoyé automatiquement le ");
html.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy à HH:mm")));
html.append("</p>");
html.append("</div></body></html>");
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("<!DOCTYPE html>");
html.append("<html><head><meta charset='UTF-8'><title>Compte verrouillé</title></head><body>");
html.append("<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>");
html.append("<h1 style='color: #e74c3c;'>Votre compte a été temporairement verrouillé</h1>");
html.append("<p>Bonjour ").append(user.getFirstName()).append(" ").append(user.getLastName()).append(",</p>");
html.append("<p>Votre compte ").append(applicationName).append(" a été temporairement verrouillé suite à plusieurs tentatives de connexion échouées.</p>");
html.append("<p><strong>Durée du verrouillage :</strong> ").append(lockDurationMinutes).append(" minutes</p>");
html.append("<p>Vous pourrez vous reconnecter après cette période.</p>");
html.append("<p style='color: #e74c3c;'><strong>Mesure de sécurité :</strong> 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.</p>");
html.append("<p>Si vous avez des questions concernant la sécurité de votre compte, contactez-nous immédiatement.</p>");
html.append("<p>Cordialement,<br>L'équipe GBCM</p>");
html.append("<hr style='margin: 20px 0; border: none; border-top: 1px solid #eee;'>");
html.append("<p style='font-size: 12px; color: #7f8c8d;'>Cet email a été envoyé automatiquement le ");
html.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy à HH:mm")));
html.append("</p>");
html.append("</div></body></html>");
return html.toString();
}
}

View File

@@ -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<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

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

View File

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