
Sécurisation avec JWT (JSON Web Tokens)
Apprenez à implémenter une authentification stateless pour vos API REST Spring Boot en utilisant les JSON Web Tokens (JWT) avec Spring Security. Guide complet sur la génération et la validation.
Introduction à JWT et à l'authentification stateless
Lorsque l'on développe des API REST, notamment dans une architecture microservices ou pour des applications frontend modernes (SPA, mobile), l'approche traditionnelle de l'authentification basée sur les sessions côté serveur montre ses limites. Chaque requête devant être indépendante (stateless), maintenir une session devient complexe et nuit à la scalabilité. Les JSON Web Tokens (JWT) offrent une solution élégante à ce problème.
Un JWT est une norme ouverte (RFC 7519) qui définit un moyen compact et autonome de transmettre en toute sécurité des informations entre parties sous forme d'objet JSON. Ces informations peuvent être vérifiées et approuvées car elles sont signées numériquement. Un JWT se compose de trois parties séparées par des points (.) :
- Header (En-tête) : Contient typiquement le type de token (JWT) et l'algorithme de signature utilisé (ex: HMAC SHA256 ou RSA). Encodé en Base64Url.
- Payload (Charge utile) : Contient les 'claims' (revendications). Ce sont des déclarations sur une entité (généralement l'utilisateur) et des métadonnées supplémentaires. On y trouve des claims enregistrés (ex: iss - émetteur, exp - date d'expiration, sub - sujet/utilisateur), publics ou privés (informations personnalisées comme les rôles, permissions, etc.). Encodé en Base64Url.
- Signature : Pour vérifier que le message n'a pas été modifié et, dans le cas des signatures HMAC, pour vérifier l'expéditeur du JWT. Elle est calculée en utilisant l'en-tête encodé, la charge utile encodée, un secret (avec HMAC) ou une clé privée (avec RSA/ECDSA), et l'algorithme spécifié dans l'en-tête.
Le principal avantage de JWT est son caractère stateless. Une fois le token émis par le serveur après une authentification réussie, le client l'inclut dans chaque requête ultérieure (généralement dans l'en-tête HTTP Authorization avec le préfixe Bearer). Le serveur peut alors valider le token et identifier l'utilisateur sans avoir besoin de consulter une base de données de sessions ou un état côté serveur, ce qui améliore grandement la scalabilité et simplifie l'architecture.
Exemple de structure décodée (avant encodage Base64Url) :
// Header
{
"alg": "HS256",
"typ": "JWT"
}
// Payload
{
"sub": "user123",
"name": "Alice",
"roles": ["USER", "EDITOR"],
"iss": "https://auth.example.com",
"iat": 1678886400,
"exp": 1678890000
}
// Signature
// HMACSHA256(
// base64UrlEncode(header) + "." +
// base64UrlEncode(payload),
// votre_secret_super_secret
// )
Génération de tokens JWT dans Spring Boot
Le flux typique de génération de JWT commence lorsqu'un utilisateur s'authentifie (par exemple, via un formulaire de login avec nom d'utilisateur et mot de passe). Si les informations d'identification sont valides, le serveur d'authentification génère un JWT contenant des informations sur l'utilisateur (comme son identifiant unique, ses rôles ou permissions) et une date d'expiration.
Pour manipuler les JWTs en Java et Spring Boot, plusieurs bibliothèques existent. L'une des plus populaires est Java JWT (jjwt). Vous devrez ajouter les dépendances nécessaires à votre projet :
io.jsonwebtoken
jjwt-api
0.11.5
io.jsonwebtoken
jjwt-impl
0.11.5
runtime
io.jsonwebtoken
jjwt-jackson
0.11.5
runtime
La génération du token implique de définir les claims, de choisir un algorithme de signature (souvent HMAC-SHA256 pour sa simplicité, mais RSA/ECDSA sont plus sûrs si la clé privée peut être bien protégée) et d'utiliser une clé secrète. Cette clé secrète est critique et doit être gardée confidentielle et suffisamment complexe. Ne la codez jamais en dur directement dans le code source.
Voici un exemple de service qui génère un token JWT après une authentification réussie :
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class JwtTokenProvider {
// Clé secrète chargée depuis la configuration (application.properties/yml)
// Générer une clé forte, par ex: openssl rand -base64 32
@Value("${app.jwt.secret}")
private String jwtSecretString;
@Value("${app.jwt.expiration-ms}")
private long jwtExpirationInMs;
private SecretKey getSigningKey() {
// Convertir la chaîne Base64 en clé utilisable par JJWT
byte[] keyBytes = java.util.Base64.getDecoder().decode(jwtSecretString);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(Authentication authentication) {
String username = authentication.getName();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
// Récupérer les rôles/autorités de l'utilisateur authentifié
List roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
SecretKey key = getSigningKey();
return Jwts.builder()
.setSubject(username)
.claim("roles", roles) // Ajout de claims personnalisés
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(key, SignatureAlgorithm.HS256) // Utilisation de l'algorithme HS256
.compact();
}
}
Validation des tokens JWT et intégration avec Spring Security
Lorsqu'un client effectue une requête sur une ressource protégée, il doit inclure le JWT obtenu précédemment. La convention est d'utiliser l'en-tête HTTP Authorization avec le schéma Bearer : Authorization: Bearer .
Côté serveur, il faut mettre en place un filtre Spring Security qui intercepte chaque requête entrante. Ce filtre a pour rôle :
- D'extraire le token JWT de l'en-tête Authorization.
- De valider le token : vérifier la signature avec la clé secrète, s'assurer qu'il n'a pas expiré, et éventuellement vérifier d'autres claims comme l'émetteur (iss) ou l'audience (aud).
- Si le token est valide, d'extraire les informations de l'utilisateur (sujet, rôles/permissions) depuis les claims.
- De créer un objet Authentication (par exemple, UsernamePasswordAuthenticationToken) représentant l'utilisateur authentifié.
- De placer cet objet Authentication dans le SecurityContextHolder de Spring Security. Cela rend l'identité de l'utilisateur disponible pour le reste de la chaîne de traitement de la requête et pour les vérifications d'autorisation ultérieures (par ex., avec @PreAuthorize).
Voici un exemple de méthode pour valider un token et extraire le nom d'utilisateur (sujet) en utilisant la même clé secrète et la bibliothèque JJWT :
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SignatureException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// ... autres imports (JwtTokenProvider, etc.)
public class JwtTokenProvider { // Suite de la classe précédente
private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
// ... (getSigningKey, generateToken) ...
public String getUsernameFromJWT(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
// Récupère les rôles du token
@SuppressWarnings("unchecked")
public List getRolesFromJWT(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("roles", List.class);
}
public boolean validateToken(String authToken) {
try {
Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(authToken);
return true;
} catch (SignatureException ex) {
logger.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
logger.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
logger.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
logger.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
logger.error("JWT claims string is empty.");
}
return false;
}
}
Ce fournisseur de token sera ensuite utilisé par un filtre personnalisé. Ce filtre étend généralement OncePerRequestFilter pour garantir qu'il n'est exécuté qu'une seule fois par requête.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
String username = tokenProvider.getUsernameFromJWT(jwt);
List roles = tokenProvider.getRolesFromJWT(jwt);
// Créer l'objet Authentication
List authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Placer l'objet Authentication dans le SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
Configuration de Spring Security pour JWT
La dernière étape consiste à configurer Spring Security pour utiliser notre filtre JWT et définir les règles d'accès. Cela se fait généralement en définissant un bean SecurityFilterChain.
Les points clés de la configuration pour une API REST sécurisée par JWT sont :
- Désactiver CSRF : La protection CSRF repose sur les sessions, ce qui n'est pas pertinent pour une API stateless.
- Configurer la gestion de session en STATELESS : Indique à Spring Security de ne pas créer ou utiliser de sessions HTTP.
- Configurer un point d'entrée pour l'authentification (AuthenticationEntryPoint) : Gère les cas où un utilisateur non authentifié tente d'accéder à une ressource protégée (généralement en retournant une erreur 401 Unauthorized).
- Ajouter le filtre JWT : Intégrer notre JwtAuthenticationFilter dans la chaîne de filtres Spring Security, avant le filtre standard d'authentification par formulaire ou HTTP Basic (par exemple, avant UsernamePasswordAuthenticationFilter).
- Définir les règles d'autorisation : Utiliser authorizeHttpRequests pour spécifier quelles URLs nécessitent une authentification, quels rôles/permissions sont requis, etc. Les endpoints d'authentification (ex: /api/auth/login) et d'enregistrement doivent souvent être publics (permitAll()).
Voici un exemple de configuration SecurityFilterChain :
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // Pour @PreAuthorize etc.
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private JwtAuthenticationEntryPoint unauthorizedHandler; // Un point d'entrée personnalisé pour 401
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Désactiver CSRF car nous utilisons JWT (stateless)
.csrf(csrf -> csrf.disable())
// Gérer les exceptions d'authentification via le point d'entrée
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
// Configurer la gestion de session en STATELESS
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Définir les règles d'autorisation
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/auth/**").permitAll() // Endpoints d'authentification publics
.requestMatchers("/api/public/**").permitAll() // Autres endpoints publics
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Nécessite le rôle ADMIN
.anyRequest().authenticated() // Toutes les autres requêtes nécessitent une authentification
);
// Ajouter notre filtre JWT avant le filtre UsernamePasswordAuthenticationFilter
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Bonnes pratiques et considérations de sécurité
Bien que JWT simplifie l'authentification stateless, certaines précautions sont essentielles :
- Sécurité de la clé secrète : Si vous utilisez un algorithme HMAC (HS256, HS512), la clé secrète est partagée entre la génération et la validation. Sa compromission permettrait à quiconque de forger des tokens valides. Stockez-la de manière sécurisée (variables d'environnement, gestionnaire de secrets comme Vault, AWS Secrets Manager, Azure Key Vault) et assurez-vous qu'elle est suffisamment longue et aléatoire. Pour les algorithmes asymétriques (RSA, ECDSA), seule la clé privée doit être protégée, la clé publique peut être partagée pour la validation.
- Utiliser HTTPS : Le token JWT est souvent transmis dans l'en-tête Authorization. Sans HTTPS, il circule en clair sur le réseau et peut être intercepté (attaque Man-in-the-Middle). Imposez HTTPS pour toutes les communications.
- Expiration courte : Donnez aux tokens une durée de vie relativement courte (quelques minutes à quelques heures). Cela limite la fenêtre d'opportunité si un token est volé.
- Gestion de la révocation (Refresh Tokens) : Un inconvénient majeur de JWT est que, par nature stateless, un token valide le reste jusqu'à son expiration, même si l'utilisateur se déconnecte ou si ses droits sont révoqués. Une stratégie courante est d'utiliser des tokens d'accès (access tokens) à courte durée de vie et des tokens de rafraîchissement (refresh tokens) à durée de vie plus longue. Le refresh token est stocké de manière plus sécurisée (parfois en base de données côté serveur ou en cookie HttpOnly) et permet d'obtenir de nouveaux access tokens sans redemander les identifiants. La révocation peut alors se faire en invalidant le refresh token. Une autre approche, moins stateless, est de maintenir une liste noire (blacklist) des tokens révoqués que le serveur consulte lors de la validation.
- Ne pas stocker d'informations sensibles dans le Payload : Le payload est encodé en Base64Url, pas chiffré. N'importe qui peut le décoder. N'y mettez pas de mots de passe, de numéros de carte de crédit ou d'autres données sensibles.
- Validation complète : Assurez-vous de toujours valider la signature, l'expiration, et potentiellement l'émetteur (iss) et l'audience (aud) si vous les utilisez, pour éviter des failles.