Spring Boot Security : Authentification JWT

17 min readPar SpringCraft

Tu as développé ton API REST avec Spring Boot. Elle fonctionne. Les endpoints CRUD répondent correctement. Mais un problème majeur subsiste : n'importe qui peut appeler n'importe quel endpoint.

En production, cette situation est intenable. Un utilisateur lambda ne doit pas pouvoir supprimer des données. Un service externe ne doit pas accéder aux endpoints d'administration. Et surtout, impossible de tracer qui fait quoi.

Spring Security résout ce problème, mais sa réputation de complexité est méritée. Entre la documentation officielle dense, les tutoriels obsolètes (WebSecurityConfigurerAdapter déprécié depuis Spring Boot 2.7), et les implémentations JWT académiques, difficile de s'y retrouver.

Cet article te montre comment implémenter un système d'authentification JWT fonctionnel et maintenable, de A à Z.

Contexte technique

  • Spring Boot 3.2+
  • Java 17+
  • API REST stateless (sans sessions serveur)
  • Base de données pour stocker les utilisateurs

Le vrai problème de la sécurité en entreprise

La question n'est jamais "faut-il sécuriser ?", mais "comment le faire sans créer de la dette technique ?".

Les contraintes réelles :

  • Performance : la sécurité ne doit pas ralentir chaque requête
  • Maintenabilité : l'équipe doit comprendre le code dans 6 mois
  • Évolutivité : ajout de nouveaux rôles sans refonte
  • Traçabilité : savoir qui a fait quoi en cas d'incident

Spring Security répond à ces besoins, mais il faut l'utiliser correctement.

JWT vs Sessions : quel choix pour ton API ?

Avant de plonger dans le code, clarifions un point crucial.

Sessions serveur (cookies)

  • État côté serveur (Redis, base de données)
  • Révocation immédiate possible
  • Scalabilité horizontale plus complexe
  • Idéal pour applications monolithiques

JWT (JSON Web Token)

  • Stateless : aucun état serveur
  • Scalabilité horizontale naturelle
  • Révocation complexe (sauf avec refresh tokens)
  • Idéal pour microservices et APIs REST

En pratique : pour une API REST moderne consommée par un frontend SPA ou une app mobile, JWT est le bon choix. Pour une application web traditionnelle avec rendu serveur, les sessions restent pertinentes.

Nous allons nous concentrer sur JWT, car c'est le cas d'usage le plus fréquent en entreprise.

Flux d'authentification JWT : vue d'ensemble

Avant de plonger dans le code, voici comment fonctionne l'authentification JWT de bout en bout :

Ce qui se passe concrètement :

  1. Login : tu envoies email + mot de passe, tu reçois un access token et un refresh token
  2. Requêtes suivantes : tu envoies le token dans le header, l'API vérifie et te donne accès
  3. Token expiré : le refresh token permet d'obtenir un nouveau access token sans redemander le mot de passe
  4. Le token remplace la session : pas besoin de stocker l'utilisateur côté serveur

Configuration de base : Spring Security moderne

Première étape : ajouter les dépendances dans pom.xml.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.13.0</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>

Pourquoi jjwt ? C'est la bibliothèque JWT la plus utilisée en Java, activement maintenue, et avec une API claire.

Configuration Security (approche moderne)

Fini WebSecurityConfigurerAdapter, voici la configuration actuelle :

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
 
    private final JwtAuthenticationFilter jwtAuthFilter;
    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(csrf -> csrf.disable()) // API REST stateless
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll() // Login/register publics
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
 
        return http.build();
    }
}

Points clés :

  • csrf().disable() : pour une API REST stateless, CSRF n'est pas pertinent (protection contre les attaques cross-site sur les sessions)
  • SessionCreationPolicy.STATELESS : pas de session serveur
  • JwtAuthenticationFilter : filtre custom qui valide le JWT à chaque requête
  • @EnableMethodSecurity : permet @PreAuthorize sur les méthodes

AuthenticationProvider personnalisé

Spring Security a besoin de savoir comment charger les utilisateurs :

@Configuration
public class ApplicationConfig {
 
    private final UserRepository userRepository;
 
    public ApplicationConfig(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
 
    @Bean
    public UserDetailsService userDetailsService() {
        return username -> userRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }
 
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }
}

Pourquoi BCrypt ? BCrypt est un algorithme de hachage cryptographique conçu spécifiquement pour chiffrer les mots de passe. Contrairement à MD5 ou SHA, il intègre un "coût" (nombre d'itérations) configurable qui ralentit intentionnellement le calcul. Cela rend les attaques par force brute extrêmement coûteuses en temps, et ce coût peut être augmenté au fil des années pour s'adapter à l'augmentation de la puissance de calcul des attaquants.

Architecture des composants Spring Security

Voici comment les différentes pièces s'assemblent :

Le parcours d'une requête :

  1. Filtre JWT : intercepte et valide le token
  2. Vérification : signature + expiration du token
  3. Chargement : récupère l'utilisateur depuis la base
  4. Autorisation : vérifie les rôles (@PreAuthorize)
  5. Réponse : OK si tout est bon, sinon 401 ou 403

Entité User et UserDetails

Ton entité User doit implémenter UserDetails :

@Entity
@Table(name = "users")
public class User implements UserDetails {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(unique = true, nullable = false)
    private String email;
 
    @Column(nullable = false)
    private String password;
 
    @Enumerated(EnumType.STRING)
    private Role role;
 
    @Column(nullable = false)
    private boolean enabled = true;
 
    // UserDetails implementation
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }
 
    @Override
    public String getUsername() {
        return email; // On utilise l'email comme username
    }
 
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
 
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
 
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
 
    @Override
    public boolean isEnabled() {
        return enabled;
    }
 
    // Getters/Setters standards
}

Attention : préfixe ROLE_ obligatoire pour Spring Security. Sans ça, hasRole("USER") ne fonctionnera pas.

public enum Role {
    USER,
    ADMIN,
    MANAGER
}

Service JWT : génération et validation

Le cœur du système JWT. Ce service a trois responsabilités principales :

  1. Générer des tokens : créer un access token et un refresh token lors du login
  2. Valider des tokens : vérifier la signature et l'expiration à chaque requête API
  3. Extraire les informations : récupérer l'email de l'utilisateur depuis le token

En pratique, tu l'utiliseras dans deux contextes :

  • AuthenticationService : pour générer les tokens après un login réussi
  • JwtAuthenticationFilter : pour valider le token à chaque requête et extraire l'utilisateur
@Service
public class JwtService {
 
    @Value("${jwt.secret}")
    private String secretKey;
 
    @Value("${jwt.expiration}")
    private long jwtExpiration;
 
    @Value("${jwt.refresh-token.expiration}")
    private long refreshExpiration;
 
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }
 
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }
 
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }
 
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return buildToken(extraClaims, userDetails, jwtExpiration);
    }
 
    public String generateRefreshToken(UserDetails userDetails) {
        return buildToken(new HashMap<>(), userDetails, refreshExpiration);
    }
 
    private String buildToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails,
            long expiration
    ) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSignInKey())
                .compact();
    }
 
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }
 
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
 
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }
 
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSignInKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
 
    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Configuration dans application.yml :

jwt:
  secret: ${JWT_SECRET}
  expiration: 900000 # 15 minutes
  refresh-token:
    expiration: 604800000 # 7 jours

Sécurité critique :

  • JWT_SECRET JAMAIS en dur dans le code : c'est une clé cryptographique (pas un simple mot de passe), utilisée pour signer et vérifier les tokens JWT. Si quelqu'un récupère cette clé, il peut générer des tokens valides pour n'importe quel utilisateur.
  • Minimum 256 bits (32 caractères) pour HMAC-SHA256
  • Durée courte pour l'access token (15-30 min)
  • Refresh token plus long (7 jours à 1 mois)

Qu'est-ce que le JWT_SECRET exactement ?

C'est une clé secrète cryptographique (suite de caractères aléatoires) qui sert à :

  • Signer le token JWT lors de sa génération (garantit que c'est ton serveur qui l'a créé)
  • Vérifier le token JWT lors de sa validation (détecte toute modification du token)

Ce n'est pas un mot de passe utilisateur, mais une clé de chiffrement symétrique : la même clé sert à signer ET vérifier.

Génère une clé sécurisée avec cette commande (à exécuter une seule fois) :

openssl rand -base64 32

Cette commande génère 32 octets aléatoires (256 bits) encodés en Base64. Le résultat ressemble à 3cfa76ef14937c1c0ea519f8fc057a80fbb114b92aa48bda8e7cc72f1c0e65e1.

Utilisation :

  1. Exécute la commande pour générer ta clé
  2. Copie le résultat
  3. Stocke-le comme variable d'environnement JWT_SECRET sur ton serveur
  4. Ne la commite JAMAIS dans Git

En développement local, ajoute-la dans un fichier .env (à mettre dans .gitignore) :

JWT_SECRET=3cfa76ef14937c1c0ea519f8fc057a80fbb114b92aa48bda8e7cc72f1c0e65e1

Filtre JWT : validation à chaque requête

Le filtre qui intercepte chaque requête et valide le JWT :

@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(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
 
        final String authHeader = request.getHeader("Authorization");
 
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
 
        final String jwt = authHeader.substring(7);
        final String userEmail = jwtService.extractUsername(jwt);
 
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);
 
            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
 
        filterChain.doFilter(request, response);
    }
}

Fonctionnement :

  1. Extraction du JWT depuis le header Authorization: Bearer <token>
  2. Validation du token (signature + expiration)
  3. Chargement de l'utilisateur depuis la base
  4. Stockage dans SecurityContextHolder pour la durée de la requête

Que se passe-t-il concrètement ?

Voici les différents scénarios selon ton token :

Légende :

  • 401 Unauthorized : pas de token ou token invalide → il faut se reconnecter
  • 403 Forbidden : token valide mais rôle insuffisant → tu n'as pas le droit
  • 200 OK : tout est bon, tu reçois les données

Controller d'authentification

Endpoints pour login et refresh token :

@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
 
    private final AuthenticationService authenticationService;
 
    public AuthenticationController(AuthenticationService authenticationService) {
        this.authenticationService = authenticationService;
    }
 
    @PostMapping("/register")
    public ResponseEntity<AuthenticationResponse> register(
            @Valid @RequestBody RegisterRequest request
    ) {
        return ResponseEntity.ok(authenticationService.register(request));
    }
 
    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> login(
            @Valid @RequestBody LoginRequest request
    ) {
        return ResponseEntity.ok(authenticationService.login(request));
    }
 
    @PostMapping("/refresh")
    public ResponseEntity<AuthenticationResponse> refreshToken(
            @RequestBody RefreshTokenRequest request
    ) {
        return ResponseEntity.ok(authenticationService.refreshToken(request));
    }
}

DTOs associés :

public record RegisterRequest(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8) String password,
    @NotBlank String firstName,
    @NotBlank String lastName
) {}
 
public record LoginRequest(
    @NotBlank @Email String email,
    @NotBlank String password
) {}
 
public record RefreshTokenRequest(
    @NotBlank String refreshToken
) {}
 
public record AuthenticationResponse(
    String accessToken,
    String refreshToken,
    String tokenType
) {
    public AuthenticationResponse(String accessToken, String refreshToken) {
        this(accessToken, refreshToken, "Bearer");
    }
}

Service d'authentification

@Service
public class AuthenticationService {
 
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;
 
    public AuthenticationService(UserRepository userRepository,
                                 PasswordEncoder passwordEncoder,
                                 JwtService jwtService,
                                 AuthenticationManager authenticationManager) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtService = jwtService;
        this.authenticationManager = authenticationManager;
    }
 
    public AuthenticationResponse register(RegisterRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new DuplicateResourceException("Email already exists");
        }
 
        var user = new User();
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));
        user.setFirstName(request.firstName());
        user.setLastName(request.lastName());
        user.setRole(Role.USER);
        user.setEnabled(true);
 
        userRepository.save(user);
 
        var accessToken = jwtService.generateToken(user);
        var refreshToken = jwtService.generateRefreshToken(user);
 
        return new AuthenticationResponse(accessToken, refreshToken);
    }
 
    public AuthenticationResponse login(LoginRequest request) {
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.email(),
                request.password()
            )
        );
 
        var user = userRepository.findByEmail(request.email())
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
 
        var accessToken = jwtService.generateToken(user);
        var refreshToken = jwtService.generateRefreshToken(user);
 
        return new AuthenticationResponse(accessToken, refreshToken);
    }
 
    public AuthenticationResponse refreshToken(RefreshTokenRequest request) {
        final String userEmail = jwtService.extractUsername(request.refreshToken());
 
        var user = userRepository.findByEmail(userEmail)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
 
        if (!jwtService.isTokenValid(request.refreshToken(), user)) {
            throw new InvalidTokenException("Invalid refresh token");
        }
 
        var accessToken = jwtService.generateToken(user);
        var newRefreshToken = jwtService.generateRefreshToken(user);
 
        return new AuthenticationResponse(accessToken, newRefreshToken);
    }
}

Points importants :

  • Validation de l'unicité de l'email avant création
  • Encodage du mot de passe avec BCrypt
  • Rôle USER par défaut
  • Génération simultanée access + refresh tokens

Gestion des rôles et autorisations

Approche 1 : Configuration globale

Dans SecurityConfig :

.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN")

Avantage : vision centralisée de la sécurité. Inconvénient : difficile à maintenir si beaucoup d'endpoints.

Approche 2 : Annotations sur les méthodes (recommandé)

Active @EnableMethodSecurity dans ta config, puis :

@RestController
@RequestMapping("/api/users")
public class UserController {
 
    private final UserService userService;
 
    public UserController(UserService userService) {
        this.userService = userService;
    }
 
    @GetMapping("/me")
    public ResponseEntity<UserDTO> getCurrentUser() {
        // Accessible à tous les utilisateurs authentifiés
        return ResponseEntity.ok(userService.getCurrentUser());
    }
 
    @GetMapping
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<List<UserDTO>> getAllUsers() {
        // Accessible uniquement aux admins
        return ResponseEntity.ok(userService.getAllUsers());
    }
 
    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
 
    @PutMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    public ResponseEntity<UserDTO> updateUser(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request
    ) {
        // Admin peut modifier n'importe qui, user peut se modifier lui-même
        return ResponseEntity.ok(userService.updateUser(id, request));
    }
}

Pourquoi cette approche ?

  • Sécurité visible au niveau du code métier
  • Facile à tester unitairement
  • Évite la duplication entre config et controllers

Récupérer l'utilisateur connecté

Dans un controller ou service :

@Service
public class UserService {
 
    private final UserRepository userRepository;
 
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
 
    public UserDTO getCurrentUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String email = auth.getName();
 
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
 
        return UserDTO.fromEntity(user);
    }
}

Ou injecter directement dans le controller :

@GetMapping("/me")
public ResponseEntity<UserDTO> getCurrentUser(@AuthenticationPrincipal User user) {
    return ResponseEntity.ok(UserDTO.fromEntity(user));
}

CORS : erreur n°1 en production

Si ton frontend est sur un domaine différent (ex: localhost:3000 pour React), tu vas rencontrer des erreurs CORS.

Configuration correcte :

@Configuration
public class CorsConfig {
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
 
        configuration.setAllowedOrigins(List.of(
            "http://localhost:3000",
            "https://mon-app.com"
        ));
 
        configuration.setAllowedMethods(List.of(
            "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
        ));
 
        configuration.setAllowedHeaders(List.of(
            "Authorization",
            "Content-Type",
            "X-Requested-With"
        ));
 
        configuration.setExposedHeaders(List.of("Authorization"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
 
        return source;
    }
}

Puis dans SecurityConfig :

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        // ...reste de la config
}

En production :

  • Jamais allowedOrigins("*") avec allowCredentials(true)
  • Liste explicite des domaines autorisés
  • Utiliser des variables d'environnement pour les URLs

Voir aussi : Configuration Spring Boot multi-environnements

Gestion des erreurs d'authentification

Sans configuration, Spring Security renvoie des 401/403 vides. Peu exploitable côté frontend.

Handler custom :

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
 
    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException
    ) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
 
        Map<String, Object> error = Map.of(
            "timestamp", LocalDateTime.now().toString(),
            "status", 403,
            "error", "Forbidden",
            "message", "You don't have permission to access this resource",
            "path", request.getRequestURI()
        );
 
        response.getWriter().write(new ObjectMapper().writeValueAsString(error));
    }
}
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
 
    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException
    ) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
 
        Map<String, Object> error = Map.of(
            "timestamp", LocalDateTime.now().toString(),
            "status", 401,
            "error", "Unauthorized",
            "message", "Authentication required",
            "path", request.getRequestURI()
        );
 
        response.getWriter().write(new ObjectMapper().writeValueAsString(error));
    }
}

Configuration dans SecurityConfig :

http
    .exceptionHandling(exception -> exception
        .authenticationEntryPoint(customAuthenticationEntryPoint)
        .accessDeniedHandler(customAccessDeniedHandler)
    )

Encore mieux : utiliser ton @ControllerAdvice global.

Voir : Gestion des exceptions dans Spring Boot

Anti-patterns JWT à éviter

1. Secret JWT en dur ou faible

Erreur :

private String secretKey = "mySecretKey123";

Correct :

jwt:
  secret: ${JWT_SECRET}

Avec une vraie clé de 256 bits minimum.

2. JWT sans expiration ou trop long

Erreur :

.expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 365)) // 1 an

Correct :

// Access token : 15-30 minutes
// Refresh token : 7 jours à 1 mois

Pourquoi ? Si un JWT est volé, impossible de le révoquer. Plus il est court, moins le risque est élevé.

3. Pas de refresh token

Si tu génères uniquement un access token long, tu ne peux pas le révoquer en cas de compromission.

Solution : système access + refresh token comme montré ci-dessus.

4. CORS mal configuré

Erreur :

configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowCredentials(true);

Incompatible et dangereux.

Correct : liste explicite d'origines.

5. Préfixe ROLE_ oublié

@PreAuthorize("hasRole('ADMIN')")

Nécessite que GrantedAuthority soit ROLE_ADMIN, pas juste ADMIN.

6. Oublier HTTPS en production

JWT transmis en clair = vol de token facile.

Obligatoire en production : HTTPS partout.

Voir : Spring Boot en production : Checklist de déploiement

Tests de sécurité

Test unitaire du service JWT

@ExtendWith(MockitoExtension.class)
class JwtServiceTest {
 
    @InjectMocks
    private JwtService jwtService;
 
    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(jwtService, "secretKey",
            "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970");
        ReflectionTestUtils.setField(jwtService, "jwtExpiration", 900000L);
    }
 
    @Test
    void shouldGenerateValidToken() {
        UserDetails user = User.builder()
            .email("test@example.com")
            .password("password")
            .role(Role.USER)
            .build();
 
        String token = jwtService.generateToken(user);
 
        assertThat(token).isNotNull();
        assertThat(jwtService.extractUsername(token)).isEqualTo("test@example.com");
        assertThat(jwtService.isTokenValid(token, user)).isTrue();
    }
}

Test d'intégration avec MockMvc

@SpringBootTest
@AutoConfigureMockMvc
class AuthenticationControllerIntegrationTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Autowired
    private ObjectMapper objectMapper;
 
    @Test
    void shouldAuthenticateUser() throws Exception {
        LoginRequest request = new LoginRequest("user@example.com", "password");
 
        mockMvc.perform(post("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.accessToken").exists())
            .andExpect(jsonPath("$.refreshToken").exists());
    }
 
    @Test
    void shouldDenyAccessWithoutToken() throws Exception {
        mockMvc.perform(get("/api/users/me"))
            .andExpect(status().isUnauthorized());
    }
 
    @Test
    void shouldAllowAccessWithValidToken() throws Exception {
        String token = authenticateAndGetToken();
 
        mockMvc.perform(get("/api/users/me")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }
}

Voir aussi : Tests dans Spring Boot

Conclusion

Tu as maintenant un système d'authentification JWT fonctionnel et production-ready.

Ce que tu as appris :

  • Configuration Spring Security moderne (sans WebSecurityConfigurerAdapter)
  • Implémentation complète JWT avec access et refresh tokens
  • Gestion des rôles avec @PreAuthorize
  • Gestion des erreurs 401/403
  • Configuration CORS
  • Tests de sécurité

Ce système JWT est prêt pour la production, avec des tokens courts, des refresh tokens, et une gestion d'erreurs propre.

Pour aller plus loin (OAuth2, rate limiting, monitoring), consulte : Spring Boot Security : OAuth2, Rate Limiting et Monitoring.

Articles liés :