Retour aux articles

Sécuriser des APIs REST avec Spring Security 6 et JWT: Guide complet

Sécuriser des APIs REST avec Spring Security 6 et JWT: Guide complet | Laty Gueye Samba - Développeur Full Stack Dakar Sénégal, Expert Java Spring Boot Angular
```html Sécuriser des APIs REST avec Spring Security 6 et JWT : Guide complet

Sécuriser des APIs REST avec Spring Security 6 et JWT : Guide complet

La sécurisation d’API REST modernes repose souvent sur des mécanismes d’authentification stateless. Spring Security 6 combiné à JWT (JSON Web Token) permet d’authentifier des clients sans maintenir de session serveur. Ce guide décrit une approche complète, orientée production, pour protéger des endpoints, gérer le cycle de vie des jetons et renforcer la sécurité globale.

Pourquoi JWT pour des APIs REST stateless

Les clients (applications web, mobiles, services) transmettent un jeton JWT à chaque requête. Le serveur vérifie la signature et récupère les informations d’identité et d’autorisations directement depuis le token. Cette stratégie réduit la dépendance à une session persistante et s’adapte bien aux architectures microservices.

Points clés

  • Stateless : pas d’état de session côté serveur.
  • Signature : intégrité et authenticité via clé secrète ou clé publique.
  • Claims : rôles, identifiant utilisateur, expiration, etc.
  • Expiration : mitigation des risques via durée de vie courte.

Prérequis

Le guide suppose une application Spring Boot utilisant Spring Security 6. Les dépendances typiques incluent spring-boot-starter-security et une bibliothèque JWT (par exemple jjwt).

Exemple de dépendances Maven (indicatif)

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.5</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.12.5</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.12.5</version>
  <scope>runtime</scope>
</dependency>

Conception de l’authentification par JWT

Une implémentation robuste suit généralement ce schéma : (1) un endpoint d’authentification émet un JWT, (2) un filtre intercepte les requêtes entrantes, (3) le token est validé (signature, expiration, claims), (4) Spring Security reçoit l’Authentication pour appliquer les règles d’autorisation.

Format recommandé de l’en-tête

Le client envoie le token via l’en-tête : Authorization: Bearer <token>.

Configuration Spring Security 6

Spring Security 6 encourage une configuration via des beans et un modèle explicite. L’objectif est de : désactiver CSRF pour les APIs stateless, configurer les règles d’accès, intégrer un filtre JWT avant l’authentification standard.

Exemple de configuration (SecurityFilterChain)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter)
      throws Exception {

    http
      .csrf(csrf -> csrf.disable())
      .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/auth/**").permitAll()
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
      )
      .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }
}

La méthode requestMatchers permet de définir précisément les endpoints publics. Tous les autres nécessitent une authentification valide.

Générer et signer un JWT

La génération d’un token doit inclure au minimum : issuer, subject, expiration, et éventuellement des claims de rôle (ou scopes).

Exemple de service JWT

public class JwtService {

  private final Key signingKey;
  private final long expirationMillis;

  public JwtService(Key signingKey, long expirationMillis) {
    this.signingKey = signingKey;
    this.expirationMillis = expirationMillis;
  }

  public String generateToken(String username, List<String> roles) {
    Instant now = Instant.now();
    Instant exp = now.plusMillis(expirationMillis);

    Map<String, Object> claims = new HashMap<>();
    claims.put("roles", roles);

    return Jwts.builder()
        .setClaims(claims)
        .setSubject(username)
        .setIssuedAt(Date.from(now))
        .setExpiration(Date.from(exp))
        .signWith(signingKey, Jwts.SIG.HS256)
        .compact();
  }

  public Jws<Claims> parseAndValidate(String token) {
    return Jwts.parserBuilder()
        .setSigningKey(signingKey)
        .build()
        .parseClaimsJws(token);
  }
}

Conseils de sécurité sur la clé

  • Pour HS256, utiliser une clé suffisamment longue (ex. 256 bits ou plus).
  • Stocker la clé en variable d’environnement ou dans un gestionnaire de secrets.
  • Envisager RS256 avec clé publique/privée pour une séparation plus propre des responsabilités.

Filtre JWT : validation et construction de l’Authentication

Le filtre lit l’en-tête Authorization, extrait le token, valide la signature et l’expiration, puis convertit les claims en GrantedAuthority pour Spring Security.

Exemple de JwtAuthenticationFilter

public class JwtAuthenticationFilter extends OncePerRequestFilter {

  private final JwtService jwtService;

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

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

    String authHeader = request.getHeader("Authorization");

    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
      filterChain.doFilter(request, response);
      return;
    }

    String token = authHeader.substring(7);

    try {
      Jws<Claims> jws = jwtService.parseAndValidate(token);
      Claims claims = jws.getBody();

      String username = claims.getSubject();
      @SuppressWarnings("unchecked")
      List<String> roles = (List<String>) claims.get("roles");

      List<GrantedAuthority> authorities = roles.stream()
          .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
          .toList();

      Authentication authentication = new UsernamePasswordAuthenticationToken(
          username,
          null,
          authorities
      );

      SecurityContextHolder.getContext().setAuthentication(authentication);

    } catch (JwtException ex) {
      // Option : effacer le contexte ou rejeter explicitement.
      SecurityContextHolder.clearContext();
    }

    filterChain.doFilter(request, response);
  }
}

En cas de token invalide (signature incorrecte, expiration, structure non conforme), le filtre n’installe pas d’authentification. Les règles d’autorisation peuvent ensuite répondre par 401 Unauthorized ou 403 Forbidden selon le cas.

Gestion des rôles et des autorisations

Les rôles doivent être cohérents entre le système d’authentification et les règles Spring. Spring attend souvent un préfixe ROLE_ dans les autorités.

Exemple de contrôleurs protégés

@RestController
@RequestMapping("/api")
public class DemoController {

  @GetMapping("/users/me")
  public Map<String, Object> me(Authentication authentication) {
    return Map.of(
      "username", authentication.getName(),
      "authorities", authentication.getAuthorities()
    );
  }

  @GetMapping("/admin/dashboard")
  @PreAuthorize("hasRole('ADMIN')")
  public String adminOnly() {
    return "Zone administrateur";
  }
}

En complément, l’activation @EnableMethodSecurity peut être nécessaire pour utiliser @PreAuthorize.

Endpoints d’authentification (émission du JWT)

Le processus d’émission du token doit être limité aux endpoints nécessaires. Un endpoint typique /api/auth/login reçoit identifiant et mot de passe, valide la combinaison, puis renvoie un JWT.

Exemple simplifié d’endpoint de login

@RestController
@RequestMapping("/api/auth")
public class AuthController {

  private final AuthenticationManager authenticationManager;
  private final JwtService jwtService;

  public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) {
    this.authenticationManager = authenticationManager;
    this.jwtService = jwtService;
  }

  @PostMapping("/login")
  public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) {
    Authentication auth = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(request.username(), request.password())
    );

    String username = auth.getName();
    List<String> roles = auth.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .map(r -> r.replace("ROLE_", ""))
        .toList();

    String token = jwtService.generateToken(username, roles);
    return ResponseEntity.ok(Map.of("accessToken", token));
  }
}

Pour une mise en production, il est recommandé d’ajouter de la protection contre le brute-force (rate limiting) et de contrôler finement les erreurs (messages et codes).

Implémentation des règles d’erreur (401/403)

Les réponses doivent refléter la situation : 401 pour absence/invalidité de token, 403 pour token valide mais droits insuffisants.

Approche via exception handling

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter)
    throws Exception {

  http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .exceptionHandling(ex -> ex
      .authenticationEntryPoint((req, res, authEx) -> res.setStatus(401))
      .accessDeniedHandler((req, res, accessEx) -> res.setStatus(403))
    )
    .authorizeHttpRequests(auth -> auth
      .requestMatchers("/api/auth/**").permitAll()
      .anyRequest().authenticated()
    )
    .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

  return http.build();
}

Bonnes pratiques de sécurité JWT

Durée de vie courte et rafraîchissement

Une durée de vie courte limite l’impact en cas de fuite. Pour gérer l’expérience utilisateur, l’usage de refresh tokens (avec une rotation) est souvent préférable.

Révocation : limites du stateless

Le JWT ne permet pas une révocation immédiate sans stratégie additionnelle. Les approches possibles incluent : stockage des identifiants de token (liste de révocation), tokens très courts, ou un serveur d’autorisation centralisé.

Vérification systématique

  • Signature : obligatoire.
  • Expiration : obligatoire.
  • Audience / Issuer : recommandé si utilisés.
  • Claims : validation de la cohérence (ex. rôles attendus).

Validation supplémentaire : CORS, HTTPS et en-têtes

Même avec un JWT robuste, des protections complémentaires améliorent la posture globale. Il est recommandé d’utiliser HTTPS, de configurer correctement CORS et de définir des en-têtes de sécurité (ex. Content Security Policy côté front, et en-têtes HTTP pertinents).

Exemple de configuration CORS (optionnel)

@Bean
public CorsConfigurationSource corsConfigurationSource() {
  CorsConfiguration config = new CorsConfiguration();
  config.setAllowedOrigins(List.of("https://app.exemple.com"));
  config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
  config.setAllowedHeaders(List.of("Authorization","Content-Type"));
  config.setExposedHeaders(List.of("Authorization"));
  config.setAllowCredentials(false);

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

Observabilité : journalisation contrôlée

Le logging aide à diagnostiquer les erreurs, mais doit éviter de divulguer des informations sensibles. Un bon compromis consiste à : (1) loguer les motifs de rejet sans inclure le token, (2) corréler avec un identifiant de requête, (3) surveiller les tentatives invalides.

Checklist de mise en production

  • Clé JWT stockée hors du code.
  • Expiration courte + stratégie refresh (si nécessaire).
  • Validation issuer/audience (si applicables) et signature.
  • Filtre JWT intégré avant l’authentification standard.
  • Règles d’accès cohérentes (roles/authorities).
  • Gestion des erreurs 401/403 conforme.
  • Rate limiting sur les endpoints d’auth.
  • HTTPS obligatoire.

Conclusion

Sécuriser une API REST avec Spring Security 6 et JWT nécessite plus que la simple génération d’un token. La robustesse repose sur une validation stricte, une configuration d’accès claire, un filtrage cohérent et des pratiques de sécurité adaptées à l’écosystème stateless. Avec les patterns présentés, une base fiable et maintenable est possible pour des applications professionnelles.

À propos de l'auteur

Laty Gueye Samba est développeur Full Stack basé à Dakar, Sénégal. Spécialiste des écosystèmes Java / Spring Boot et Angular.

Contact : latygueyesamba@gmail.com  |  Dakar, Sénégal