Chapitre 3: JWT (JSON Web Tokens)
Introduction
Les sessions sont parfaites pour les applications web traditionnelles, mais les API REST ont besoin d'être stateless (sans état). JWT (JSON Web Token) est la solution standard pour authentifier les requêtes API sans stocker de session côté serveur.
🎯 Objectifs du Chapitre
À la fin de ce chapitre, vous saurez:
- ✅ Comprendre le fonctionnement de JWT
- ✅ Décoder la structure d'un token
- ✅ Générer et valider des tokens
- ✅ Implémenter un filtre d'authentification JWT
- ✅ Gérer les refresh tokens
1. Pourquoi JWT?
Session vs JWT
| Aspect | Session | JWT |
|---|---|---|
| Stockage | Serveur (mémoire/BDD) | Client (localStorage/cookie) |
| Scalabilité | Difficile (sticky sessions) | Facile (stateless) |
| Mobile | Compliqué | Parfait |
| Microservices | Problématique | Idéal |
Flow JWT
2. Structure d'un JWT
Les 3 Parties
Un JWT est composé de 3 parties séparées par des points:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
└──────────── HEADER ──────────────────┴────────────────────── PAYLOAD ────────────────────────────────┴── SIGNATURE ──
Header (En-tête)
{
"alg": "HS256", // Algorithme de signature
"typ": "JWT" // Type de token
}
Encodé en Base64: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload (Charge Utile)
{
"sub": "1234567890", // Subject (ID utilisateur)
"name": "John Doe", // Claims personnalisés
"email": "john@example.com",
"roles": ["ROLE_USER"],
"iat": 1516239022, // Issued At (timestamp)
"exp": 1516242622 // Expiration (timestamp)
}
Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
attention
Le payload est encodé, pas chiffré! N'y mettez JAMAIS de données sensibles (mot de passe, carte bancaire...).
3. Implémentation avec Spring Boot
3.1 Dépendances
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JJWT (Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
</dependencies>
3.2 Configuration
# application.properties
jwt.secret=votreCleSecreteTresLongueEtComplexeAvecAuMoins256Bits
jwt.expiration=86400000
jwt.refresh-expiration=604800000
@Configuration
@ConfigurationProperties(prefix = "jwt")
@Data
public class JwtProperties {
private String secret;
private long expiration; // 24h en ms
private long refreshExpiration; // 7 jours en ms
}
3.3 JwtService
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtProperties jwtProperties;
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecret());
return Keys.hmacShaKeyFor(keyBytes);
}
// Générer un Access Token
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration()))
.signWith(getSigningKey())
.compact();
}
// Générer un Refresh Token
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshExpiration()))
.signWith(getSigningKey())
.compact();
}
// Extraire le username
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Extraire une claim
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
// Valider le token
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);
}
}
3.4 JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Récupérer le header Authorization
final String authHeader = request.getHeader("Authorization");
// Vérifier le format Bearer
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Extraire le token
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
// Vérifier si pas déjà authentifié
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// Charger l'utilisateur
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// Valider le token
if (jwtService.isTokenValid(jwt, userDetails)) {
// Créer l'authentification
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Stocker dans le SecurityContext
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
3.5 SecurityConfig pour JWT
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Désactiver CSRF pour API REST
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}
4. Controller d'Authentification
DTOs
// Requête de login
@Data
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
}
// Requête d'inscription
@Data
public class RegisterRequest {
@NotBlank
@Size(min = 3, max = 50)
private String username;
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 6)
private String password;
}
// Réponse avec tokens
@Data
@Builder
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String username;
private List<String> roles;
}
// Requête de refresh
@Data
public class RefreshTokenRequest {
@NotBlank
private String refreshToken;
}
AuthController
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtService jwtService;
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
// Authentifier
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// Charger l'utilisateur
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
// Générer les tokens
String accessToken = jwtService.generateToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return ResponseEntity.ok(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.username(userDetails.getUsername())
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.build());
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
// Créer l'utilisateur
User user = userService.registerUser(
request.getUsername(),
request.getEmail(),
request.getPassword()
);
// Charger les UserDetails
UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername());
// Générer les tokens
String accessToken = jwtService.generateToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return ResponseEntity.status(HttpStatus.CREATED)
.body(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.username(user.getUsername())
.roles(List.of("ROLE_USER"))
.build());
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
String username = jwtService.extractUsername(request.getRefreshToken());
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(request.getRefreshToken(), userDetails)) {
String accessToken = jwtService.generateToken(userDetails);
return ResponseEntity.ok(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(request.getRefreshToken()) // Réutiliser
.username(username)
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.build());
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
5. Test avec cURL
Login
# Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"password"}'
# Réponse:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"username": "user",
"roles": ["ROLE_USER"]
}
Accès Protégé
# Sans token
curl http://localhost:8080/api/users
# 401 Unauthorized
# Avec token
curl http://localhost:8080/api/users \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
# 200 OK
Refresh Token
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"eyJhbGciOiJIUzI1NiJ9..."}'
6. Bonnes Pratiques
Stockage du Token (Frontend)
// ✅ httpOnly Cookie (le plus sécurisé)
// Géré par le serveur
// ⚠️ localStorage (vulnérable XSS)
localStorage.setItem('token', response.accessToken);
// ⚠️ sessionStorage (meilleur que localStorage)
sessionStorage.setItem('token', response.accessToken);
Durée de Vie
Access Token: 15 minutes - 1 heure (court)
Refresh Token: 7 jours - 30 jours (long)
Révocation
// Option 1: Blacklist en BDD
@Entity
public class RevokedToken {
private String tokenId;
private LocalDateTime revokedAt;
}
// Option 2: Version utilisateur
// Changer user.tokenVersion invalide tous les anciens tokens
7. Résumé
Structure JWT
HEADER.PAYLOAD.SIGNATURE
Composants Clés
| Composant | Rôle |
|---|---|
| JwtService | Génère et valide les tokens |
| JwtAuthenticationFilter | Intercepte et traite les tokens |
| AuthController | Endpoints login/register/refresh |
Flow Complet
1. POST /api/auth/login → Access + Refresh tokens
2. GET /api/data (Bearer token) → Données
3. Token expiré → POST /api/auth/refresh → Nouveau Access token
🎯 Prochaine Étape
JWT est parfait pour l'authentification directe, mais parfois on veut déléguer l'authentification à un tiers (Google, GitHub...). C'est le rôle d'OAuth2.
➡️ Chapitre 4: OAuth2 et Login Social