Série « Spring MVC et Spring Boot » : partie 3 sur 3.
Au sommaire de la série :
- Contrôleurs, DTO et conteneur Spring
- Validation, erreurs et pagination
- Sécurité JWT et tests (cette partie)
Partie précédente : Validation, erreurs et pagination
Les deux premières parties ont construit une API qui valide ses entrées et répond toujours dans le même format d'erreur (contrôleurs, DTO et conteneur Spring, puis validation, erreurs et pagination). Cette dernière partie la protège. Une API REST sans sécurité est un serveur de fichiers public : Spring Security, intégré à Spring Boot, verrouille les endpoints par authentification et par rôle, avec une granularité fine.
Cette partie présente le minimum pour qu'un contrôleur REST soit réellement protégé : la chaîne de filtres, la lecture du token, son émission et le contrôle d'accès au niveau des méthodes, puis les tests de ces règles. Les sujets qui demandent plus de place (refresh token, révocation, point d'entrée JSON, CORS, stockage du token côté client) sont traités dans la série sur l'application Spring Boot complète. Les exemples ont été compilés et exécutés avec Spring Boot 3.5, donc Spring Security 6.5.
Le code suppose les classes des parties précédentes (UserRepository avec findByEmail(String) et existsByEmail(String), UserService, le PasswordEncoder de AppConfig, le GlobalExceptionHandler) et une entité User dont le champ role est une énumération. Les dépendances à ajouter sont spring-boot-starter-security, ainsi que jjwt-api, jjwt-impl et jjwt-jackson (version 0.12.6 pour les exemples), les deux derniers en portée runtime.
La configuration globale avec SecurityFilterChain
La première étape est de définir quels endpoints sont publics, lesquels nécessitent une authentification et lesquels sont réservés à certains rôles :
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // active @PreAuthorize sur les méthodes
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // pas de CSRF sur une API stateless
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
// 401 plutôt que 403 pour une requête sans token valide
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth
// Endpoints publics
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
// Endpoints réservés aux administrateurs
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// Suppression de commentaires : modérateurs et administrateurs
.requestMatchers(HttpMethod.DELETE, "/api/v1/comments/**")
.hasAnyRole("ADMIN", "MODERATOR")
// Tout le reste exige une authentification
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// Nécessaire pour injecter l'AuthenticationManager dans le service de connexion
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}
Le bean AuthenticationManager est nécessaire pour l'injecter dans le service de connexion. Pas besoin de déclarer un AuthenticationProvider : tant qu'un seul bean UserDetailsService et un bean PasswordEncoder existent, Spring Security construit lui-même le DaoAuthenticationProvider utilisé par l'AuthenticationManager.
L'ordre des requestMatchers est important : Spring Security les évalue de haut en bas et s'arrête à la première correspondance. Si anyRequest().authenticated() était en premier, tous les endpoints seraient protégés, y compris /api/v1/auth/login. Les règles les plus spécifiques viennent avant les règles générales. Les routes publiques sont restreintes à GET : ouvrir /api/v1/products/** sans précision de méthode rendrait aussi publics les POST, PUT et DELETE sur les produits.
Le csrf.disable() n'est justifié que pour une API stateless authentifiée par token. L'attaque CSRF exploite les cookies de session que le navigateur envoie automatiquement. Une API qui authentifie via un en-tête Authorization: Bearer ... n'est pas concernée, puisque le navigateur n'envoie jamais cet en-tête de lui-même. Si le token est un jour stocké dans un cookie, la protection CSRF redevient nécessaire.
Charger l'utilisateur depuis la base
Spring Security a besoin d'un UserDetailsService pour retrouver un utilisateur à partir de son identifiant. Ici, l'identifiant est l'e-mail. La classe s'appelle CustomUserDetailsService, le nom employé dans les exemples de la documentation de référence de Spring Security (page « UserDetailsService » de la version 6.5). Le nom UserDetailsServiceImpl circule aussi dans les tutoriels, mais le suffixe Impl n'apporte rien quand une seule classe implémente l'interface. Spring Security n'impose aucun nom, seule l'interface compte.
@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 email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Utilisateur introuvable"));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.roles(user.getRole().name()) // ADMIN devient l'autorité ROLE_ADMIN
.build();
}
}
La méthode roles("ADMIN") ajoute le préfixe ROLE_, ce qui permet à hasRole('ADMIN') de fonctionner. Avec authorities("ADMIN"), il faudrait écrire hasAuthority('ADMIN').
Le filtre JWT : valider le token à chaque requête
Le filtre JWT intercepte chaque requête HTTP, extrait le token de l'en-tête Authorization, le valide et injecte l'utilisateur authentifié dans le contexte de sécurité :
@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 authHeader = request.getHeader("Authorization");
// Pas de header Bearer : on passe au filtre suivant sans authentifier
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String jwt = authHeader.substring(7);
try {
String username = jwtService.extractUsername(jwt);
if (SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails.isEnabled() && jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (JwtException | IllegalArgumentException | UsernameNotFoundException ex) {
// Token expiré, falsifié ou utilisateur supprimé : la requête reste anonyme,
// et Spring Security répond 401 sur les endpoints protégés
}
filterChain.doFilter(request, response);
}
}
Le OncePerRequestFilter garantit que le filtre s'exécute une seule fois par requête, même si la requête est redirigée en interne. Spring Boot enregistre en plus tout bean de type Filter dans la chaîne du conteneur de servlets : sans cette garantie, le filtre pourrait s'exécuter deux fois. Le filterChain.doFilter() est appelé dans tous les cas, y compris quand le token est absent : les requêtes sans token atteignent normalement les endpoints permitAll() et sont rejetées par Spring Security sur les endpoints protégés.
Le try/catch est indispensable. Les méthodes de parsing de jjwt lèvent des exceptions (ExpiredJwtException, MalformedJwtException, SignatureException...) pour tout token invalide, toutes héritières de JwtException. Une exception levée dans un filtre échappe au @RestControllerAdvice, puisqu'un filtre s'exécute avant le DispatcherServlet : un simple token expiré ferait planter la requête en 500 au lieu de renvoyer un 401.
Écrire ce filtre soi-même aide à comprendre le mécanisme, mais Spring Security sait aussi valider des JWT sans filtre maison, avec spring-boot-starter-oauth2-resource-server et .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())). Sur un projet réel, cette option est à considérer en premier.
Le service JWT : génération et validation des tokens
Le code suivant utilise l'API de jjwt 0.12 (jjwt-api, jjwt-impl, jjwt-jackson). Plusieurs méthodes de l'ancienne API sont dépréciées dans cette version, par exemple signWith(key, SignatureAlgorithm) et setClaims(), et Jwts.parserBuilder() de la 0.11 n'existe plus : Jwts.parser() renvoie directement le builder. Cette vérification a été faite sur la 0.12.6.
@Service
public class JwtService {
private final SecretKey signingKey;
private final long accessTokenExpiration;
public JwtService(@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-expiration}") long accessTokenExpiration) {
this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
this.accessTokenExpiration = accessTokenExpiration;
}
public String generateAccessToken(String username) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.issuedAt(now)
.expiration(new Date(now.getTime() + accessTokenExpiration))
.signWith(signingKey, Jwts.SIG.HS256)
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
// parseClaims vérifie la signature et l'expiration, et lève une exception sinon
return extractUsername(token).equals(userDetails.getUsername());
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
Les propriétés se déclarent dans application.yml, le secret venant d'une variable d'environnement :
jwt:
secret: ${JWT_SECRET}
access-token-expiration: 900000
Le secret JWT vient d'une variable d'environnement ou d'une propriété hors dépôt, jamais du code. S'il est compromis, n'importe qui peut forger des tokens valides. Il doit être aléatoire, d'au moins 256 bits pour HS256 (par exemple openssl rand -base64 32), et stocké dans un gestionnaire de secrets en production. Une clé plus courte est refusée par jjwt avec une WeakKeyException, ce qui fait échouer le démarrage plutôt que de signer avec une clé faible.
La durée de vie du token est volontairement courte. Un JWT stateless ne peut pas être révoqué avant son expiration sans mécanisme supplémentaire : on combine donc un access token court avec un refresh token et une liste de révocation, ce que traite la série sur l'application complète en étendant ce même JwtService.
Le contrôleur d'authentification
Le contrôleur délègue à un service d'authentification, comme les autres :
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
}
@Service
public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public AuthService(AuthenticationManager authenticationManager, JwtService jwtService) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
public AuthResponse login(LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password()));
// Le principal est le UserDetails chargé pendant l'authentification
UserDetails user = (UserDetails) authentication.getPrincipal();
return new AuthResponse(jwtService.generateAccessToken(user.getUsername()));
}
}
public record LoginRequest(@NotBlank @Email String email, @NotBlank String password) {
}
public record AuthResponse(String accessToken) {
}
Le authenticationManager.authenticate() vérifie les identifiants contre la base de données. Si le mot de passe est incorrect, il lance une BadCredentialsException, que le GlobalExceptionHandler transforme en 401 (handler d'AuthenticationException, voir plus bas). Réutiliser le principal renvoyé par authenticate() évite de recharger l'utilisateur en base une seconde fois.
Un détail de sécurité : le message d'erreur est le même que l'e-mail soit inconnu ou que le mot de passe soit faux. Spring Security masque d'ailleurs par défaut le UsernameNotFoundException en BadCredentialsException, ce qui évite de révéler quels e-mails existent. Le test d'intégration de cette partie le confirme : les deux cas renvoient la même réponse 401.
401 ou 403 : ce que renvoie Spring Security
Spring Security ne renvoie pas toujours un 401 à un client anonyme. Avec une chaîne de filtres sans httpBasic() ni formLogin() et sans AuthenticationEntryPoint déclaré, une requête sans token valide sur un endpoint protégé reçoit un 403 (constaté avec Spring Boot 3.5 : Spring Security utilise alors Http403ForbiddenEntryPoint). Le 403 d'un hasRole() ou d'un @PreAuthorize pour un utilisateur authentifié est, lui, correct. C'est la raison de la ligne authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) dans la configuration : elle fait renvoyer un 401 aux requêtes anonymes.
HttpStatusEntryPoint produit un 401 au corps vide. Pour un corps JSON au format ProblemDetail du reste de l'API, on écrit son propre AuthenticationEntryPoint, comme dans la partie sur la sécurité de l'application complète. Les 401 de la connexion (mauvais mot de passe) viennent d'un autre mécanisme : le service lève une AuthenticationException, que le GlobalExceptionHandler convertit en 401.
Les deux handlers de sécurité s'ajoutent à la classe de la partie précédente :
// Identifiants incorrects à la connexion (BadCredentialsException et consorts)
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleAuthentication(AuthenticationException ex) {
return problem(HttpStatus.UNAUTHORIZED, "Authentification refusée",
"Identifiants incorrects.", "BAD_CREDENTIALS");
}
// Refus de @PreAuthorize : levé depuis le contrôleur, donc intercepté ici
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
return problem(HttpStatus.FORBIDDEN, "Accès refusé",
"Droits insuffisants pour cette ressource.", "ACCESS_DENIED");
}
Le handler d'AccessDeniedException est nécessaire à cause du filet de sécurité : les refus de @PreAuthorize sont levés depuis le contrôleur, et sans handler dédié ils passeraient par le handler Exception.class, donc en 500. En revanche, un refus décidé par les règles d'URL de la chaîne de filtres (hasRole("ADMIN") sur /api/v1/admin/**) ne passe pas par le @RestControllerAdvice : il produit un 403 au corps vide, sauf si l'on déclare aussi un AccessDeniedHandler.
Sécuriser au niveau des méthodes avec @PreAuthorize
La sécurité par URL ne suffit pas quand le contrôle d'accès dépend de la logique métier : un utilisateur ne modifie que ses propres données, un modérateur supprime n'importe quel commentaire, un admin peut tout faire. @PreAuthorize exprime ces règles directement sur les méthodes (avec @EnableMethodSecurity sur la configuration). Voici les méthodes de UserController concernées, les autres restant inchangées :
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
public ResponseEntity<PageResponse<UserResponse>> findAll(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(defaultValue = "createdAt") String sortBy) {
// même liste blanche de tris que pour les produits
if (!SORTABLE_FIELDS.contains(sortBy)) {
throw new InvalidSortException(sortBy);
}
Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy).descending());
return ResponseEntity.ok(PageResponse.from(userService.findAll(pageable)));
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id)")
public ResponseEntity<UserResponse> update(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
L'expression hasRole('ADMIN') or @userSecurity.isOwner(#id) est du SpEL (Spring Expression Language) évalué avant l'exécution de la méthode. #id désigne le paramètre de la méthode (son nom est résolu grâce à l'option de compilation -parameters, activée par défaut avec le parent Maven de Spring Boot), et @userSecurity un bean qu'il faut écrire soi-même :
@Component("userSecurity")
public class UserSecurity {
private final UserRepository userRepository;
public UserSecurity(UserRepository userRepository) {
this.userRepository = userRepository;
}
public boolean isOwner(Long userId) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return false;
}
return userRepository.findByEmail(auth.getName())
.map(user -> user.getId().equals(userId))
.orElse(false);
}
}
On pourrait être tenté d'écrire directement #id == authentication.principal.id. Cela ne fonctionne qu'avec un principal qui expose un id : le UserDetails standard de Spring Security (org.springframework.security.core.userdetails.User) ne contient que le nom d'utilisateur, le mot de passe et les autorités, et l'expression échoue alors à l'évaluation. L'autre solution est un principal personnalisé, renvoyé par le CustomUserDetailsService :
public class CustomUserDetails extends User {
private final Long id;
public CustomUserDetails(Long id, String username, String password,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.id = id;
}
public Long getId() {
return id;
}
}
Avec ce principal, #id == authentication.principal.id est valide (l'expression a été évaluée sur un CustomUserDetails pour le vérifier), et @AuthenticationPrincipal CustomUserDetails user donne accès à l'identifiant dans les méthodes du contrôleur. Le bean userSecurity a l'avantage de rester lisible quand la règle se complique (propriétaire d'une ressource liée, état du compte), et de se tester comme une classe ordinaire.
Tester la sécurité
Dès qu'un filtre JWT existe, les tests de contrôleur changent. Les beans de type Filter font partie de la tranche web de @WebMvcTest, donc JwtAuthenticationFilter est instancié dans tous les tests de contrôleur, y compris ceux des parties précédentes. Sans ses dépendances, le contexte échoue au démarrage avec une NoSuchBeanDefinitionException. Ce comportement a été constaté sur le ProductControllerTest de la deuxième partie, qui cessait de démarrer. D'où les deux @MockitoBean supplémentaires :
@WebMvcTest(UserController.class)
@AutoConfigureMockMvc(addFilters = false) // on teste le contrôleur, pas la chaîne de sécurité
class UserControllerTest {
private static final String VALID_BODY = """
{"name":"Awa","email":"awa@example.com","password":"correct-horse-battery"}
""";
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
// @WebMvcTest charge aussi les beans de type Filter : le filtre JWT réclame ses dépendances
@MockitoBean
private JwtService jwtService;
@MockitoBean
private UserDetailsService userDetailsService;
@Test
void createUser_returns201WithLocation() throws Exception {
when(userService.create(any(CreateUserRequest.class))).thenReturn(
new UserResponse(42L, "Awa", "awa@example.com", LocalDateTime.of(2026, 1, 15, 10, 0)));
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_BODY))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "http://localhost/api/v1/users/42"))
.andExpect(jsonPath("$.password").doesNotExist());
}
}
addFilters = false désactive la chaîne de sécurité pour ce test : on vérifie le contrôleur, pas les règles d'accès. Celles-ci se testent à part, avec la vraie chaîne de filtres : spring-security-test fournit @WithMockUser(roles = "USER") pour jouer un utilisateur connecté, et l'absence d'annotation joue le visiteur anonyme.
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
// Dépendances du filtre JWT et de l'expression SpEL, inutiles pour ces scénarios
@MockitoBean
private JwtService jwtService;
@MockitoBean
private UserDetailsService userDetailsService;
@MockitoBean(name = "userSecurity")
private UserSecurity userSecurity;
@Test
void deleteUser_anonymous_returns401() throws Exception {
mockMvc.perform(delete("/api/v1/users/1"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void deleteUser_asSimpleUser_returns403() throws Exception {
mockMvc.perform(delete("/api/v1/users/1"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void deleteUser_asAdmin_returns204() throws Exception {
mockMvc.perform(delete("/api/v1/users/1"))
.andExpect(status().isNoContent());
}
}
Dans ces trois tests, le DELETE ne touche pas la base : le service est un double, et seul compte le verdict de la chaîne de sécurité. Un échec d'un de ces tests pointe directement une règle requestMatchers ou @PreAuthorize qui n'a plus l'effet attendu.
Un test d'intégration complet (@SpringBootTest avec @AutoConfigureMockMvc) complète ces tests : il crée deux utilisateurs, appelle /api/v1/auth/login, puis réutilise le token. Il a permis de vérifier le comportement suivant avec une vraie base H2.
| Requête | Résultat |
|---|---|
GET /api/v1/users sans token |
401 |
GET /api/v1/products sans token (route publique) |
200 |
| Connexion avec un mauvais mot de passe, ou avec un e-mail inconnu | 401, même réponse |
GET /api/v1/users avec le token d'un simple utilisateur |
403 |
GET /api/v1/users/{id} sur son propre compte |
200 |
GET /api/v1/users/{id} sur le compte d'un autre |
403 |
GET /api/v1/users?sortBy=password en administrateur |
400 (tri refusé) |
Token falsifié ou en-tête Authorization sans Bearer |
401 |
À retenir de la série
- La chaîne de filtres décide de l'accès par URL ;
@PreAuthorizegère les règles qui dépendent des données. - Un
AuthenticationEntryPointexplicite est nécessaire pour obtenir un 401 plutôt qu'un 403 pour un anonyme. - Le filtre JWT ne doit jamais laisser une exception de parsing remonter : un token invalide donne une requête anonyme.
- Le secret JWT vient de l'environnement et fait au moins 256 bits.
- Valider côté serveur même si le frontend valide déjà : le frontend peut être contourné, et un
curlenvoie ce qu'il veut. - Ne jamais journaliser les corps de requête qui contiennent des mots de passe.
- Ne jamais supprimer un champ d'un DTO de réponse sans versionner : ajouter un champ est rétrocompatible, en supprimer un casse les clients.
- Tester les cas d'erreur, pas seulement les cas nominaux : un test qui vérifie qu'un POST avec un e-mail dupliqué renvoie 409 évite des régressions.
Pour aller plus loin, la série sur l'application Spring Boot complète reprend ces briques (entités, mappers, refresh token, documentation OpenAPI) dans un projet de bout en bout.
Série « Spring MVC et Spring Boot » : partie 3 sur 3.
Au sommaire de la série :
- Contrôleurs, DTO et conteneur Spring
- Validation, erreurs et pagination
- Sécurité JWT et tests (cette partie)
Partie précédente : Validation, erreurs et pagination