Implémentation d'une Protection contre la Soumission Multiple des Requêtes de Commande via une Annotation Personnalisée

Ce mécanisme vise à prévenir la soumission répétée de requêtes de commande, ce qui pourrait entraîner la création de commandes dupliquées.

Approches pour la Prévention de la Soumission Multiple

  • Côté Client : Désactiver le bouton de commande après le premier clic. Cette méthode est limitée car elle peut être contournée et dégrade l'expérience utilisateur.
  • Côté Serveur : Utiliser un aspect (AOP) avec une annotation personnalisée pour intercepter et gérer les requêtes potentiellement dupliquées.

Nous allons nous concentrer sur l'implémentation côté serveur.

Annotation personnalisée @RepeatSubmit

Cette annotation permet de spécifier le type de protection contre la soumission multiple et la durée du verrouillage.


import java.lang.annotation.*;

/**
* Annotation personnalisée pour la prévention de la soumission multiple.
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {

   /**
    * Type de prévention : basé sur les paramètres de la requête ou sur un token.
    */
   enum PreventionType {
       PARAMETER, TOKEN
   }

   /**
    * Définit le type de prévention. Par défaut, utilise les paramètres de la requête.
    * @return Le type de prévention.
    */
   PreventionType preventionType() default PreventionType.PARAMETER;

   /**
    * Durée du verrouillage en secondes. Par défaut, 5 secondes.
    * @return Durée du verrouillage.
    */
   long lockDuration() default 5;
}
 

Aspect de Prévention de Soumission Multiple

Cet aspect intercepte les méthodes annotées avec @RepeatSubmit et implémente la logique de protection.


import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

/**
* Aspect pour la prévention de la soumission multiple des requêtes.
*/
@Aspect
@Component
public class RepeatSubmitAspect {

   private static final Logger logger = LoggerFactory.getLogger(RepeatSubmitAspect.class);

   @Autowired
   private StringRedisTemplate redisTemplate;

   @Autowired
   private RedissonClient redissonClient;

   /**
    * Point de coupure pour les méthodes annotées avec @RepeatSubmit.
    * @param repeatSubmit L'annotation @RepeatSubmit.
    */
   @Pointcut("@annotation(repeatSubmit)")
   public void preventRepeatSubmitPointcut(RepeatSubmit repeatSubmit) {}

   /**
    * Notification autour de l'exécution de la méthode.
    *
    * @param joinPoint      L'objet représentant la méthode appelée.
    * @param repeatSubmit   L'annotation @RepeatSubmit attachée à la méthode.
    * @return Le résultat de l'appel de la méthode.
    * @throws Throwable En cas d'erreur lors de l'exécution.
    */
   @Around("preventRepeatSubmitPointcut(repeatSubmit)")
   public Object aroundPreventRepeatSubmit(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
       HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
       // Récupérer l'identifiant de l'utilisateur (exemple, à adapter selon votre système d'authentification)
       Long userId = getCurrentUserId(); // Assurez-vous que cette méthode récupère correctement l'ID utilisateur

       boolean acquiredLock = false;
       RepeatSubmit.PreventionType type = repeatSubmit.preventionType();

       if (type == RepeatSubmit.PreventionType.PARAMETER) {
           // Protection basée sur les paramètres de la requête
           long duration = repeatSubmit.lockDuration();
           String clientIp = getClientIpAddress(request);
           MethodSignature signature = (MethodSignature) joinPoint.getSignature();
           Method method = signature.getMethod();
           String methodKey = String.format("order-lock:%s-%s-%s-%d", clientIp, method.getDeclaringClass().getName(), method.getName(), userId);

           RLock lock = redissonClient.getLock(methodKey);
           // Essayer d'acquérir le verrou pendant 2 secondes, avec une durée de validité de 'duration' secondes.
           acquiredLock = lock.tryLock(2, duration, TimeUnit.SECONDS);

       } else if (type == RepeatSubmit.PreventionType.TOKEN) {
           // Protection basée sur un token
           String requestToken = request.getHeader("X-Request-Token"); // Utiliser un header personnalisé
           if (StringUtils.isEmpty(requestToken)) {
               logger.error("Le token de requête est manquant.");
               // Adapter l'exception selon votre gestion d'erreurs
               throw new CustomApiException("Le token de requête est requis.");
           }
           String tokenKey = String.format("order-token:%d:%s", userId, requestToken);
           // Supprimer le token s'il existe dans Redis, indiquant que la requête est valide et traitée.
           acquiredLock = redisTemplate.delete(tokenKey) != null;
       }

       if (!acquiredLock) {
           logger.warn("Tentative de soumission répétée détectée pour l'utilisateur {}.", userId);
           // Retourner null ou une réponse appropriée pour indiquer l'échec.
           return null;
       }

       logger.debug("Verrou acquis, traitement de la requête.");
       Object result = joinPoint.proceed();
       logger.debug("Traitement de la requête terminé.");

       return result;
   }

   /**
    * Méthode utilitaire pour obtenir l'adresse IP du client.
    * À adapter selon votre configuration réseau (proxies, load balancers).
    */
   private String getClientIpAddress(HttpServletRequest request) {
       String ip = request.getHeader("X-Forwarded-For");
       if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
           ip = request.getHeader("Proxy-Client-IP");
       }
       if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
           ip = request.getHeader("WL-Proxy-Client-IP");
       }
       if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
           ip = request.getRemoteAddr();
       }
       return ip;
   }

   /**
    * Méthode placeholder pour récupérer l'ID utilisateur actuel.
    * À implémenter en fonction de votre système d'authentification (ex: ThreadLocal, SecurityContext).
    */
   private Long getCurrentUserId() {
       // Exemple: return SecurityContextHolder.getContext().getAuthentication().getPrincipal().getId();
       // Ou si vous utilisez un ThreadLocal comme dans l'exemple original:
       // return LoginInterceptor.threadLocal.get().getAccountNo();
       // Pour cet exemple, nous retournons une valeur fictive.
       return 12345L;
   }
}
 

Configuration Redisson

Cette configuration est nécessaire pour utiliser Redisson afin de gérer les verrous distribués.


import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Configuration pour Redisson, une bibliothèque de verrous distribués.
*/
@Configuration
public class RedissonConfiguration {

   @Value("${spring.redis.host}")
   private String redisHost;

   @Value("${spring.redis.port}")
   private String redisPort;

   @Value("${spring.redis.password}")
   private String redisPassword;

   /**
    * Configuration du client Redisson pour une instance Redis en mode standalone.
    * @return Le client Redisson configuré.
    */
   @Bean
   public RedissonClient redissonClient() {
       Config config = new Config();
       // Configuration pour une instance Redis en mode standalone
       config.useSingleServer()
             .setPassword(redisPassword)
             .setAddress("redis://" + redisHost + ":" + redisPort);

       // Pour une configuration en cluster, décommentez et adaptez la section suivante :
       /*
       config.useClusterServers()
             .setScanInterval(2000) // Intervalle de scan de l'état du cluster
             .addNodeAddress("redis://127.0.0.1:7000")
             .addNodeAddress("redis://127.0.0.1:7002");
       */

       return Redisson.create(config);
   }
}
 

Utilisation de l'Annotation

Appliquez l'annotation @RepeatSubmit aux méthodes de contrôleur qui nécessitent une protection contre la soumission multiple.

Exemple : Obtenir un Token pour la Protection

Cette méthode génère un token unique pour une session utilisateur, à utiliser avant de soumettre une commande.


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

@RestController
public class OrderController {

   @Autowired
   private StringRedisTemplate redisTemplate;

   // Constante pour la clé Redis, à définir dans une classe de constantes
   private static final String SUBMIT_ORDER_TOKEN_KEY_PREFIX = "order:submit:token:";

   /**
    * Génère et renvoie un token unique pour prévenir la soumission multiple.
    * @return Un objet JSON contenant le token généré.
    */
   @GetMapping("/order/generate-token")
   public JsonData generateOrderToken() {
       // Récupérer l'ID utilisateur actuel (à implémenter)
       Long userId = getCurrentUserId();
       String token = UUID.randomUUID().toString();
       String redisKey = SUBMIT_ORDER_TOKEN_KEY_PREFIX + userId + ":" + token;

       // Stocker le token dans Redis avec une expiration de 30 minutes
       redisTemplate.opsForValue().set(redisKey, String.valueOf(System.currentTimeMillis()), 30, TimeUnit.MINUTES);

       // Retourner le token à l'appelant (ex: dans un objet JsonData)
       return JsonData.buildSuccess(token);
   }

   // ... autres méthodes du contrôleur

   // Placeholder pour la méthode récupérant l'ID utilisateur
   private Long getCurrentUserId() {
       // Implémentation à adapter selon votre système d'authentification
       return 12345L;
   }

   // Classe placeholder pour la réponse JSON (à adapter)
   static class JsonData {
       public static JsonData buildSuccess(Object data) { return new JsonData(); }
   }
}
 

Exemple : Endpoint de Confirmation de Commande

L'annotation @RepeatSubmit est appliquée à la méthode de confirmation de commande, spécifiant l'utilisation du type TOKEN pour la prévention.


import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;

@RestController
public class OrderController {

   // ... autres méthodes

   /**
    * Confirme une commande après vérification de la prévention de soumission multiple via token.
    * @param orderRequest Les détails de la commande à confirmer.
    * @param response L'objet HttpServletResponse.
    */
   @PostMapping("/order/confirm")
   @RepeatSubmit(preventionType = RepeatSubmit.PreventionType.TOKEN)
   public void confirmOrder(@RequestBody ConfirmOrderRequest orderRequest, HttpServletResponse response) {
       // Logique métier de confirmation de commande ici...
       System.out.println("Confirmation de commande initiée...");
   }

   // Classe placeholder pour la requête de commande (à adapter)
   static class ConfirmOrderRequest {}

   // ... autres méthodes et classes
}
 

Alternativement, pour utiliser la protection basée sur les paramètres de la requête :


import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

   // ... autres méthodes

   /**
    * Endpoint protégé par la prévention de soumission basée sur les paramètres.
    * @param orderRequest Les détails de la commande.
    */
   @PostMapping("/order/submit-params")
   @RepeatSubmit(preventionType = RepeatSubmit.PreventionType.PARAMETER, lockDuration = 10) // Verrou de 10 secondes
   public void submitOrderWithParams(@RequestBody OrderDetails orderRequest) {
       // Logique métier...
       System.out.println("Soumission de commande via paramètres...");
   }

   // Classe placeholder pour les détails de commande (à adapter)
   static class OrderDetails {}

   // ... autres méthodes et classes
}
 

Étiquettes: Java Spring Boot AOP Redis Redisson

Publié le 14 juin à 21h17