Contactez-nous

Intégration de JWT avec Spring Security (filtres d'authentification)

Apprenez à intégrer l'authentification basée sur JWT (JSON Web Tokens) dans Spring Security en créant et configurant des filtres d'authentification personnalisés pour vos API stateless.

JWT : le standard pour l'authentification stateless

JSON Web Token (JWT) est un standard ouvert (RFC 7519) qui définit une manière compacte et autonome de transmettre des informations de manière sécurisée entre les parties sous forme d'objet JSON. Dans le contexte des API REST stateless, les JWT sont devenus la méthode de prédilection pour gérer l'authentification après la connexion initiale.

Un JWT se compose de trois parties séparées par des points (`.`): l'en-tête (Header), la charge utile (Payload), et la signature. L'en-tête spécifie l'algorithme de signature, la charge utile contient les informations (appelées "claims") sur l'utilisateur (comme l'identifiant, les rôles, la date d'expiration), et la signature vérifie l'authenticité et l'intégrité du token. Comme le token contient les informations nécessaires (et est signé), le serveur n'a pas besoin de maintenir une session, ce qui est idéal pour les architectures stateless et les microservices.

Lorsqu'un utilisateur se connecte avec succès (par exemple, via un endpoint `/login` avec identifiant/mot de passe), le serveur génère un JWT et le renvoie au client. Le client doit ensuite inclure ce JWT dans l'en-tête `Authorization` (généralement avec le préfixe `Bearer `) de chaque requête ultérieure vers les endpoints protégés de l'API. Le rôle de Spring Security est alors d'intercepter ces requêtes, d'extraire le JWT, de le valider, et d'établir le contexte de sécurité pour la requête.

Spring Security et JWT : la nécessité d'une approche personnalisée

Contrairement à des mécanismes comme l'authentification par formulaire (`formLogin`) ou HTTP Basic (`httpBasic`), Spring Security n'offre pas de configurateur direct et prêt à l'emploi comme `http.jwt()`. La raison est que les détails d'implémentation des JWT (génération, validation, contenu des claims) peuvent varier considérablement d'une application à l'autre.

Par conséquent, l'intégration de l'authentification JWT avec Spring Security implique généralement la création de composants personnalisés, notamment un filtre d'authentification. Ce filtre sera responsable d'inspecter chaque requête entrante, de rechercher un JWT dans l'en-tête `Authorization`, de le valider si présent, et, en cas de succès, de construire un objet `Authentication` et de le placer dans le `SecurityContextHolder`.

Cette approche personnalisée offre une grande flexibilité mais demande une compréhension claire du fonctionnement des filtres Spring Security et du cycle de vie des JWT.

Création du filtre d'authentification JWT personnalisé

Le coeur de l'intégration est un filtre personnalisé qui s'exécute pour chaque requête. Il est courant de le faire hériter de `org.springframework.web.filter.OncePerRequestFilter`, qui garantit que le filtre ne s'exécute qu'une seule fois par requête.

Les responsabilités principales de ce filtre sont :

  1. Extraire le Token : Lire l'en-tête `Authorization` de la requête entrante. Vérifier s'il commence par `Bearer ` et extraire la chaîne de caractères du token.
  2. Valider le Token : Si un token est trouvé, utiliser une logique de validation (souvent encapsulée dans une classe utilitaire ou un service dédié) pour vérifier :
    • La signature (à l'aide de la clé secrète ou publique du serveur).
    • La date d'expiration.
    • Potentiellement d'autres claims (émetteur, audience...).
  3. Charger les Détails Utilisateur : Si le token est valide, extraire les informations de l'utilisateur (généralement le nom d'utilisateur ou l'ID depuis le claim `sub` - subject) et potentiellement les rôles/autorités depuis les claims personnalisés.
  4. Créer l'objet `Authentication` : Utiliser les informations extraites pour construire un objet `Authentication` valide pour Spring Security. `UsernamePasswordAuthenticationToken` est souvent utilisé à cette fin, même s'il n'y a pas de vérification de mot de passe à ce stade ; on fournit le principal (UserDetails ou nom d'utilisateur), `null` pour les credentials, et la liste des `GrantedAuthority`.
  5. Mettre à jour le Contexte de Sécurité : Placer l'objet `Authentication` créé dans le `SecurityContextHolder` (`SecurityContextHolder.getContext().setAuthentication(...)`). Cela rend l'utilisateur authentifié disponible pour le reste de la chaîne de filtres (notamment pour l'autorisation) et pour l'application.
  6. Continuer la Chaîne : Appeler `filterChain.doFilter(request, response)` pour passer la main au filtre suivant.

Exemple de structure du filtre :

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component // Enregistre le filtre comme bean Spring
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil; // Service/Utilitaire pour gérer les JWT
    private final UserDetailsService userDetailsService; // Pour charger UserDetails

    public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

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

        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;

        // 1. Vérifier si l'en-tête est présent et commence par "Bearer "
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response); // Passer au filtre suivant
            return;
        }

        // 2. Extraire le token
        jwt = authHeader.substring(7);

        try {
            // 3. Extraire le nom d'utilisateur du token (via JwtUtil)
            username = jwtUtil.extractUsername(jwt);

            // 4. Vérifier si l'utilisateur est déjà authentifié dans le contexte actuel
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                
                // 5. Charger les détails de l'utilisateur (depuis UserDetailsService)
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

                // 6. Valider le token (signature, expiration, etc. - via JwtUtil)
                if (jwtUtil.isTokenValid(jwt, userDetails)) {
                    
                    // 7. Créer l'objet Authentication
                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,        // Principal
                            null,               // Credentials (pas nécessaire pour JWT)
                            userDetails.getAuthorities() // Authorities
                    );
                    // Ajouter des détails web (IP, etc.) à l'authentification
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                    // 8. Mettre à jour le SecurityContextHolder
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (Exception e) {
            // Gérer les exceptions de validation de token (ExpiredJwtException, SignatureException, etc.)
            // Souvent, on laisse juste le contexte de sécurité vide (non authentifié)
            SecurityContextHolder.clearContext(); 
             // Log l'erreur ici si nécessaire logger.error("Cannot set user authentication: {}", e);
        }
        
        // 9. Passer au filtre suivant
        filterChain.doFilter(request, response);
    }
}

Configuration du filtre dans la chaîne de sécurité

Une fois le filtre personnalisé (`JwtAuthenticationFilter`) créé et déclaré comme bean Spring (par exemple avec `@Component`), il doit être ajouté à la chaîne de filtres de sécurité (`SecurityFilterChain`) au bon endroit. Généralement, il doit s'exécuter avant les filtres qui dépendent d'une authentification établie, comme le filtre d'autorisation principal (`FilterSecurityInterceptor`) ou les filtres d'authentification standard comme `UsernamePasswordAuthenticationFilter` (si celui-ci est utilisé pour d'autres parties de l'application ou si on veut s'assurer que l'authentification JWT a priorité).

Cela se fait dans la configuration `HttpSecurity` en utilisant la méthode `addFilterBefore()`:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider; // Si besoin
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    // Injectez aussi l'AuthenticationProvider si vous utilisez formLogin ailleurs
    // private final AuthenticationProvider authenticationProvider;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter /*, AuthenticationProvider authenticationProvider*/) {
        this.jwtAuthFilter = jwtAuthFilter;
        // this.authenticationProvider = authenticationProvider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable) // Désactiver CSRF pour API stateless
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/auth/**", "/public/**").permitAll() // Endpoints publics (login, etc.)
                .anyRequest().authenticated() // Toutes les autres requêtes nécessitent authentification
            )
            // Configuration de la session en STATELESS
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // Si vous avez un AuthenticationProvider pour formLogin par ex.
            // .authenticationProvider(authenticationProvider) 
            // Ajout du filtre JWT AVANT le filtre standard UsernamePasswordAuthenticationFilter
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
            
        return http.build();
    }
}

En plaçant `jwtAuthFilter` avant `UsernamePasswordAuthenticationFilter.class`, vous vous assurez que si un token JWT valide est présent, l'utilisateur sera authentifié via ce token, et les mécanismes d'authentification ultérieurs (comme le traitement d'un formulaire de login) ne seront potentiellement pas déclenchés pour cette requête si l'authentification JWT réussit.

Utilitaires JWT et gestion du secret

Le filtre d'authentification (`JwtAuthenticationFilter`) dépend d'une logique pour extraire les informations du token et le valider (`JwtUtil` dans l'exemple). Cette logique doit être implémentée séparément, souvent dans une classe utilitaire ou un service dédié.

Cette classe `JwtUtil` utilisera une bibliothèque JWT comme `io.jsonwebtoken:jjwt-api`, `io.jsonwebtoken:jjwt-impl`, `io.jsonwebtoken:jjwt-jackson` (ou `auth0:java-jwt`) pour :

  • Générer les tokens : (Généralement appelée depuis le contrôleur de login) Définir les claims (subject, expiration, rôles...), signer le token avec une clé secrète.
  • Valider les tokens : Parser le token reçu, vérifier la signature avec la même clé secrète, vérifier la date d'expiration.
  • Extraire les claims : Fournir des méthodes pour récupérer le nom d'utilisateur (subject) et d'autres claims (comme les rôles/autorités) depuis un token valide.

La gestion de la clé secrète (utilisée pour signer et vérifier les tokens) est absolument critique pour la sécurité. Elle ne doit jamais être codée en dur dans l'application. Utilisez des configurations externalisées, des variables d'environnement, ou mieux, des systèmes de gestion de secrets (comme HashiCorp Vault, AWS Secrets Manager, Azure Key Vault).

Conclusion : intégration flexible mais manuelle

L'intégration de l'authentification JWT dans Spring Security offre une solution robuste et standard pour sécuriser les API stateless. Bien qu'elle nécessite la création de composants personnalisés comme un filtre d'authentification et des utilitaires de gestion JWT, cette approche offre une flexibilité maximale pour adapter le processus à vos besoins spécifiques (structure des claims, logique de validation).

En comprenant le rôle du filtre personnalisé, son intégration dans la chaîne de sécurité Spring, et l'importance de la validation correcte et de la gestion sécurisée des secrets JWT, vous pouvez mettre en place une authentification stateless fiable et performante pour vos applications Spring Boot.