Spring Boot Security : Authentification JWT
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 :
- Login : tu envoies email + mot de passe, tu reçois un access token et un refresh token
- Requêtes suivantes : tu envoies le token dans le header, l'API vérifie et te donne accès
- Token expiré : le refresh token permet d'obtenir un nouveau access token sans redemander le mot de passe
- 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 serveurJwtAuthenticationFilter: filtre custom qui valide le JWT à chaque requête@EnableMethodSecurity: permet@PreAuthorizesur 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 :
- Filtre JWT : intercepte et valide le token
- Vérification : signature + expiration du token
- Chargement : récupère l'utilisateur depuis la base
- Autorisation : vérifie les rôles (
@PreAuthorize) - 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 :
- Générer des tokens : créer un access token et un refresh token lors du login
- Valider des tokens : vérifier la signature et l'expiration à chaque requête API
- 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 joursSécurité critique :
JWT_SECRETJAMAIS 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 32Cette commande génère 32 octets aléatoires (256 bits) encodés en Base64. Le résultat ressemble à 3cfa76ef14937c1c0ea519f8fc057a80fbb114b92aa48bda8e7cc72f1c0e65e1.
Utilisation :
- Exécute la commande pour générer ta clé
- Copie le résultat
- Stocke-le comme variable d'environnement
JWT_SECRETsur ton serveur - Ne la commite JAMAIS dans Git
En développement local, ajoute-la dans un fichier .env (à mettre dans .gitignore) :
JWT_SECRET=3cfa76ef14937c1c0ea519f8fc057a80fbb114b92aa48bda8e7cc72f1c0e65e1Filtre 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 :
- Extraction du JWT depuis le header
Authorization: Bearer <token> - Validation du token (signature + expiration)
- Chargement de l'utilisateur depuis la base
- Stockage dans
SecurityContextHolderpour 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
USERpar 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("*")avecallowCredentials(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 moisPourquoi ? 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 :