Construire une application Spring Boot complète : sécurité JWT (partie 2/3)

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
17 min de lecture
6 vues

Écouter l'article

Série « Construire une application Spring Boot complète » : partie 2 sur 3.

Au sommaire de la série :

  1. Entités, DTO et services
  2. Sécurité JWT (cette partie)
  3. Contrôleurs, erreurs et documentation

Partie précédente : Entités, DTO et services · Partie suivante : Contrôleurs, erreurs et documentation

La première partie a posé le socle de l'application : structure par fonctionnalité, entités, DTO, mappers et AuthService. Cette deuxième partie construit tout ce que ce service appelle : l'émission et la validation des tokens JWT, le filtre qui authentifie chaque requête, le chargement de l'utilisateur, le point d'entrée des erreurs d'authentification et la configuration de Spring Security.

La sécurité est le morceau le plus complexe d'une application Spring Boot. Les concepts s'empilent : filtres, providers, user details, service de tokens, entry points. Cette partie montre comment les pièces s'emboîtent. Elle étend la version minimale décrite dans la troisième partie de la série Spring MVC, dont elle reprend les noms : JwtService, JwtAuthenticationFilter, CustomUserDetailsService, SecurityConfig. Les exemples ont été compilés et exécutés avec Spring Boot 3.5, donc Spring Security 6.5.

La configuration application.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/myapp
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false

jwt:
  secret: ${JWT_SECRET}                 # 32 octets aléatoires encodés en Base64
  access-token-expiration: 900000       # 15 minutes (en ms)
  refresh-token-expiration: 604800000   # 7 jours (en ms)

app:
  cors:
    allowed-origins: http://localhost:3000   # plusieurs origines : séparées par des virgules

server:
  port: 8080

ddl-auto: validate est le bon réglage en production. Hibernate vérifie que les entités correspondent au schéma sans jamais le modifier. update est pratique en développement (il ajoute les tables et colonnes manquantes), mais il ne fournit ni historique, ni renommage, ni suppression : les migrations de schéma doivent passer par Flyway ou Liquibase, de façon versionnée et reproductible (voir la série sur l'auto-configuration, qui détaille Flyway).

Le secret JWT vient d'une variable d'environnement : jamais en dur dans le code, jamais dans un application.yml versionné. En production, un gestionnaire de secrets (Vault, AWS Secrets Manager, Azure Key Vault) est l'endroit approprié. Pour HS256, la clé doit faire au moins 256 bits (32 octets) et être aléatoire, par exemple générée avec openssl rand -base64 32. Un mot de passe mémorisable ne convient pas comme clé : la sécurité de la signature repose entièrement sur l'entropie de ce secret.

Le service JWT : émettre, valider, révoquer

Le service a trois responsabilités : générer les tokens, les valider et les révoquer. Le code utilise l'API de jjwt 0.12 (jjwt-api, jjwt-impl et jjwt-jackson en dépendances), comme la version minimale de la série Spring MVC, dont les méthodes extractUsername et generateAccessToken sont conservées.

Conception du service :

  • Chaque token porte une claim type (access ou refresh) et un identifiant unique jti. Sans la claim de type, un refresh token, qui vit sept jours, serait accepté partout comme un access token. Le jti permet de révoquer un token précis.
  • La liste de révocation associe le jti à la date d'expiration du token. Une entrée n'a plus d'utilité une fois le token expiré, et peut être purgée.
@Service
public class JwtService {

    private static final Logger log = LoggerFactory.getLogger(JwtService.class);

    private static final String CLAIM_TYPE = "type";
    private static final String TYPE_ACCESS = "access";
    private static final String TYPE_REFRESH = "refresh";

    private final SecretKey signingKey;
    private final long accessTokenExpiration;
    private final long refreshTokenExpiration;

    // jti -> date d'expiration du token révoqué
    private final Map<String, Instant> revokedTokens = new ConcurrentHashMap<>();

    public JwtService(@Value("${jwt.secret}") String secret,
                      @Value("${jwt.access-token-expiration}") long accessTokenExpiration,
                      @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) {
        this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
        this.accessTokenExpiration = accessTokenExpiration;
        this.refreshTokenExpiration = refreshTokenExpiration;
    }

    public String generateAccessToken(String username) {
        return generateToken(username, TYPE_ACCESS, accessTokenExpiration);
    }

    public String generateRefreshToken(String username) {
        return generateToken(username, TYPE_REFRESH, refreshTokenExpiration);
    }

    private String generateToken(String username, String type, long expirationMs) {
        Date now = new Date();
        return Jwts.builder()
                .subject(username)
                .claim(CLAIM_TYPE, type)
                .id(UUID.randomUUID().toString())
                .issuedAt(now)
                .expiration(new Date(now.getTime() + expirationMs))
                .signWith(signingKey, Jwts.SIG.HS256)
                .compact();
    }

    private Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(signingKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public String extractUsername(String token) {
        return parseClaims(token).getSubject();
    }

    public boolean isAccessTokenValid(String token) {
        return isValid(token, TYPE_ACCESS);
    }

    public boolean isRefreshTokenValid(String token) {
        return isValid(token, TYPE_REFRESH);
    }

    private boolean isValid(String token, String expectedType) {
        try {
            Claims claims = parseClaims(token);
            return expectedType.equals(claims.get(CLAIM_TYPE, String.class))
                    && !revokedTokens.containsKey(claims.getId());
        } catch (JwtException | IllegalArgumentException ex) {
            // Expiré, falsifié, mal formé : cas d'usage normal, pas une erreur du serveur
            log.debug("Token JWT invalide : {}", ex.getMessage());
            return false;
        }
    }

    public void revoke(String token) {
        try {
            Claims claims = parseClaims(token);
            Instant now = Instant.now();
            // Purge des entrées dont le token est de toute façon expiré
            revokedTokens.values().removeIf(expiry -> expiry.isBefore(now));
            revokedTokens.put(claims.getId(), claims.getExpiration().toInstant());
        } catch (JwtException | IllegalArgumentException ex) {
            log.debug("Token déjà invalide, rien à révoquer");
        }
    }

    public long getAccessTokenExpirationSeconds() {
        return accessTokenExpiration / 1000;
    }
}

Les exceptions de jjwt (ExpiredJwtException, MalformedJwtException, UnsupportedJwtException, SignatureException) héritent toutes de JwtException. Un token invalide est un cas normal d'usage (client expiré, token falsifié), d'où un niveau debug plutôt qu'error, et un retour false plutôt qu'une exception : les méthodes isAccessTokenValid et isRefreshTokenValid ne lèvent jamais d'exception pour un token invalide, ce qui simplifie le filtre.

Les limites de la révocation en mémoire. La liste de révocation est un ConcurrentHashMap local à la JVM. Elle fonctionne pour une instance unique, avec deux restrictions :

  • Au redémarrage, elle est vide : tous les tokens révoqués redeviennent valides jusqu'à leur expiration.
  • Avec plusieurs instances derrière un répartiteur de charge, un token révoqué sur l'instance A reste valide sur l'instance B.

Sans nettoyage, une telle structure grossirait indéfiniment. Ici, la purge à chaque révocation la limite aux tokens encore non expirés. La solution adaptée à plusieurs instances est de stocker le jti dans Redis avec un TTL égal à la durée de vie restante du token : Redis supprime lui-même les entrées expirées, et toutes les instances partagent la même liste.

HS256 suffit. HS256 avec une clé aléatoire de 256 bits offre une sécurité largement suffisante pour cet usage. HS512 produit une signature plus longue sans rien apporter si la clé reste de 256 bits : la force de la signature est limitée par la clé, et jjwt refuse d'ailleurs de signer en HS512 avec une clé de 256 bits (vérifié, une SignatureException est levée). Si l'API est consommée par des tiers qui doivent vérifier les tokens sans connaître le secret, un algorithme asymétrique (RS256, ES256) devient pertinent.

Le filtre JWT

Le filtre intercepte chaque requête HTTP, extrait le token de l'en-tête Authorization, le valide et injecte l'utilisateur authentifié dans le contexte Spring Security :

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String jwt = extractJwtFromRequest(request);

        if (jwt != null && jwtService.isAccessTokenValid(jwt)) {
            try {
                UserDetails userDetails =
                        userDetailsService.loadUserByUsername(jwtService.extractUsername(jwt));

                // Un compte désactivé après l'émission du token ne doit plus passer
                if (userDetails.isEnabled()) {
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (UsernameNotFoundException ex) {
                // Compte supprimé depuis l'émission du token : la requête reste anonyme
            }
        }

        filterChain.doFilter(request, response);
    }

    private String extractJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

Par rapport à la version minimale de la série Spring MVC, trois différences : seul un token de type access est accepté (un refresh token présenté comme access token est ignoré), un token révoqué est refusé, et un compte désactivé après l'émission du token ne passe plus (isEnabled()). Le catch ne cible plus que UsernameNotFoundException, puisque la validation du token ne lève rien : un catch (Exception) global masquerait aussi les pannes de base de données.

Le OncePerRequestFilter garantit une seule exécution par requête, y compris quand Spring Boot enregistre aussi le bean Filter dans la chaîne du conteneur de servlets. Le filterChain.doFilter() est appelé dans tous les cas : sans cet appel, la chaîne s'arrête et la requête n'atteint jamais le contrôleur.

Une alternative standard. Spring Security sait valider des JWT sans filtre maison, avec spring-boot-starter-oauth2-resource-server : il suffit de déclarer un JwtDecoder (NimbusJwtDecoder.withSecretKey(key).build() pour une clé HS256) et d'activer .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())). Signature et expiration sont alors vérifiées par le framework. Le filtre est écrit à la main ici pour comprendre le mécanisme, et parce qu'il permet de contrôler le type de token, la révocation et l'état du compte ; sur un projet réel, cette alternative est à considérer en premier.

Le CustomUserDetailsService

Spring Security a besoin de savoir comment charger un utilisateur depuis la base. C'est le rôle du UserDetailsService. La classe s'appelle CustomUserDetailsService, comme dans les exemples de la documentation de référence de Spring Security (page « UserDetailsService » de la version 6.5), et comme dans la série Spring MVC. Le suffixe Impl (UserDetailsServiceImpl) circule aussi dans les tutoriels, mais il n'apporte rien : l'interface porte déjà le contrat, et une seule classe l'implémente. Spring Security n'impose aucun nom.

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsernameOrEmail(username, username)
                .orElseThrow(() -> new UsernameNotFoundException("Utilisateur introuvable"));

        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .disabled(!user.isActive())
                .roles(user.getRoles().stream()
                        .map(role -> role.getName().name())
                        .toArray(String[]::new))
                .build();
    }
}

findByUsernameOrEmail(username, username) passe le même paramètre pour les deux critères : l'utilisateur peut se connecter indifféremment avec son nom d'utilisateur ou son e-mail. Le UserDetails renvoyé porte toujours le nom d'utilisateur canonique, ce qui garantit un subject JWT stable quel que soit l'identifiant saisi.

Cette requête impose une règle à l'inscription : le nom d'utilisateur ne doit pas pouvoir contenir @. Sinon, quelqu'un pourrait s'inscrire sous le nom victime@mail.com : cet identifiant désignerait alors deux comptes (le sien par nom d'utilisateur, celui de la victime par e-mail), la requête échouerait ou retiendrait le mauvais, et la connexion de la victime serait bloquée ou ambiguë. Le @Pattern du DTO d'inscription ferme cette porte (une inscription avec zoe@example.com comme nom d'utilisateur renvoie 400) ; l'autre solution est de faire deux requêtes séparées, e-mail d'abord puis nom d'utilisateur, ce qui fixe une priorité.

Le @Transactional(readOnly = true) garde la session JPA ouverte le temps de lire les rôles, ce qui permet de les laisser en LAZY. Il indique aussi au fournisseur JPA qu'aucune écriture n'est attendue.

Le .disabled(...) est une élégance de Spring Security : si le compte est désactivé, DaoAuthenticationProvider refuse l'authentification avec une DisabledException, sans cas particulier dans le service (le test d'un compte désactivé renvoie bien 401 à la connexion). Cette vérification n'a lieu qu'au login, c'est pourquoi le filtre JWT contrôle lui aussi isEnabled() : avec un token émis avant la désactivation, la requête redevient anonyme et reçoit un 401.

Ce UserDetails ne contient pas l'identifiant de la base : c'est une instance de la classe User de Spring Security, qui n'a ni id ni e-mail. Cela compte pour les expressions @PreAuthorize de la partie suivante.

Le point d'entrée pour les erreurs d'authentification

Quand une requête non authentifiée arrive sur un endpoint protégé, la réponse dépend de la configuration. Sans httpBasic() ni formLogin() et sans point d'entrée déclaré, Spring Security 6 renvoie un 403 (Http403ForbiddenEntryPoint), et non un 401. L'AuthenticationEntryPoint permet d'obtenir un 401 avec un corps JSON au format ProblemDetail, celui du reste de l'API :

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.UNAUTHORIZED, "Authentification requise pour accéder à cette ressource.");
        problem.setTitle("Non authentifié");
        problem.setProperty("code", "AUTHENTICATION_REQUIRED");
        problem.setInstance(URI.create(request.getRequestURI()));

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        objectMapper.writeValue(response.getOutputStream(), problem);
    }
}

Sans cet entry point, le client reçoit un 403 au corps vide, qui ne correspond ni au bon code ni au format du reste de l'API. Les erreurs de sécurité gagnent à suivre le même format que les autres réponses. Pour les refus d'accès décidés par les règles d'URL (403), un AccessDeniedHandler joue le même rôle : sans lui, ces refus renvoient un corps vide (constaté sur /api/v1/admin/** avec un utilisateur sans le rôle).

La configuration Security

La classe SecurityConfig assemble toutes les pièces : filtre JWT, entry point, CORS, règles d'autorisation.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                          JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register",
                                "/api/v1/auth/refresh").permitAll()
                        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/v1/articles/**").permitAll()

                        .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/v1/moderator/**").hasAnyRole("ADMIN", "MODERATOR")

                        .anyRequest().authenticated())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource(
            @Value("${app.cors.allowed-origins}") String[] allowedOrigins) {
        CorsConfiguration config = new CorsConfiguration();
        // Les origines viennent de la configuration (voir application.yml)
        config.setAllowedOrigins(List.of(allowedOrigins));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }
}

Les requestMatchers sont évalués de haut en bas et la première règle qui correspond s'applique. Les règles les plus spécifiques (/api/v1/auth/login) viennent donc avant anyRequest().authenticated(), qui sert de filet de sécurité. Les routes d'authentification sont énumérées une à une : /api/v1/auth/logout doit rester protégée, puisqu'elle révoque l'access token de l'appelant. @EnableMethodSecurity active @PreAuthorize ; prePostEnabled est vrai par défaut.

CORS. Un frontend React en développement sur localhost:3000 n'est pas sur la même origine que l'API sur localhost:8080. Une requête avec un en-tête Authorization ou un Content-Type: application/json déclenche une requête preflight (OPTIONS) : si le serveur ne l'autorise pas, le navigateur n'envoie jamais la vraie requête. Pour une requête dite « simple » (un GET sans en-tête particulier), la requête part bien et c'est la lecture de la réponse que le navigateur bloque. Dans les deux cas, le serveur doit déclarer explicitement les origines, méthodes et en-têtes autorisés. L'en-tête Authorization passe par setAllowedHeaders. Les origines viennent de la propriété app.cors.allowed-origins : http://localhost:3000 en développement, le domaine réel du frontend dans le profil de production. Avec cette configuration, un preflight depuis http://localhost:3000 reçoit un 200, et depuis une autre origine un 403 « Invalid CORS request » (constaté sur l'exemple). Un test avec Postman ou curl passe sans problème, car seul le navigateur applique la Same-Origin Policy.

setAllowCredentials(true) n'est utile que si le navigateur doit envoyer des cookies, une authentification HTTP ou des certificats clients (fetch avec credentials: 'include'). Avec un JWT placé dans un en-tête, ce n'est pas nécessaire. L'activer élargit la surface d'attaque et interdit de plus le joker * pour les origines. Pour la même raison, l'en-tête Authorization n'est pas exposé en réponse : le token est renvoyé dans le corps. setMaxAge(3600L) met la réponse du preflight en cache pendant une heure, ce qui évite un aller-retour OPTIONS avant chaque requête.

BCrypt. new BCryptPasswordEncoder(12) utilise un facteur de coût de 12 au lieu du défaut de 10. Chaque incrément double le temps de calcul : le hachage est volontairement lent, ce qui ralentit d'autant une attaque par force brute ou par dictionnaire sur une base d'empreintes volée. Cela ne la rend pas impossible : un mot de passe faible reste vulnérable. Le coût se choisit en mesurant sur le matériel de production, pour rester imperceptible à la connexion. Pour pouvoir changer d'algorithme plus tard, PasswordEncoderFactories.createDelegatingPasswordEncoder() préfixe les empreintes par l'algorithme utilisé.

CSRF. csrf.disable() se justifie ici pour la même raison que dans la série Spring MVC : l'API est stateless et le token voyage dans l'en-tête Authorization, que le navigateur n'ajoute jamais automatiquement. Si le token est un jour stocké dans un cookie, ce raisonnement ne tient plus.

Où stocker le token côté client

Le serveur émet les tokens, mais leur stockage dans le navigateur revient au frontend, et aucune option n'est sans contrepartie :

  • localStorage ou sessionStorage. Simple à mettre en œuvre, et le token n'est envoyé que lorsque le code l'ajoute à l'en-tête Authorization, donc sans risque CSRF. En contrepartie, tout JavaScript exécuté dans la page peut le lire : une faille XSS, ou une dépendance tierce compromise, permet de le voler.
  • Cookie HttpOnly, Secure et SameSite. Le JavaScript ne peut pas lire le token, ce qui limite l'impact d'une faille XSS (le script peut toujours émettre des requêtes depuis la page). Mais le navigateur envoie le cookie automatiquement : la protection CSRF est à reconsidérer (SameSite=Lax ou Strict, éventuellement un jeton CSRF), le filtre doit lire le cookie au lieu de l'en-tête, et le CORS doit autoriser les credentials.

Un compromis fréquent consiste à garder l'access token, de courte durée, en mémoire côté frontend, et à placer le refresh token dans un cookie HttpOnly limité à l'URL de rafraîchissement. Il ne supprime pas le problème, il le déplace. Quel que soit le choix, une politique de sécurité de contenu (CSP) stricte et l'échappement systématique des données affichées réduisent le risque XSS.

À retenir de cette deuxième partie

  • Un JwtService unique émet, valide et révoque les tokens ; la claim type empêche d'utiliser un refresh token comme access token.
  • Le filtre n'authentifie que des tokens d'accès valides, non révoqués, pour des comptes actifs.
  • Sans AuthenticationEntryPoint, un anonyme reçoit un 403 : le point d'entrée JSON rétablit le 401.
  • La révocation en mémoire convient à une seule instance ; Redis la partage entre plusieurs.
  • Le choix du stockage du token côté client déplace le risque entre XSS et CSRF sans le supprimer.

La dernière partie expose cette sécurité dans les contrôleurs, complète la gestion des erreurs, ajoute la documentation Swagger et liste ce qu'il reste à faire pour la production.


Série « Construire une application Spring Boot complète » : partie 2 sur 3.

Au sommaire de la série :

  1. Entités, DTO et services
  2. Sécurité JWT (cette partie)
  3. Contrôleurs, erreurs et documentation

Partie précédente : Entités, DTO et services · Partie suivante : Contrôleurs, erreurs et documentation

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.