1. Deux mondes : le jeton et l'utilisateur actuel
Quand vous intégrez pour la première fois la prise en charge JWT intégrée, vous avez l'impression que Spring Security fait quelque chose de mystique : hier nous avions juste une chaîne dans l'en-tête, et aujourd'hui dans le contrôleur, soudainement, JwtAuthenticationToken est disponible, @AuthenticationPrincipal Jwt, hasAuthority(...) se met à fonctionner… et tout cela sans votre filtre. Pour ne pas percevoir cela comme de la magie, il est utile de séparer deux mondes : le monde de la requête HTTP et le monde de Spring Security au sein de l'application.
Dans le monde HTTP, nous avons les en-têtes, la méthode, le chemin, le corps. Là, le jeton est simplement une chaîne après le mot Bearer. Dans le monde de Spring Security, le jeton se transforme en un objet concret de type Authentication, qui « vit » dans SecurityContext uniquement pendant le temps de traitement de la requête. Le chemin JWT intégré est un pipeline soigné qui s'occupe précisément de traduire d'un monde à l'autre : « chaîne de l'en-tête » → « jeton vérifié » → « utilisateur authentifié pour la requête en cours ».
Pour visualiser ce pipeline dans son ensemble, gardez en tête ce schéma :
flowchart TD R[HTTP request] --> H["Authorization: Bearer ..."] H --> F[BearerTokenAuthenticationFilter] F --> AM[AuthenticationManager] AM --> P[JwtAuthenticationProvider] P --> D[JwtDecoder] D --> J[Jwt] J --> C[JwtAuthenticationConverter] C --> AT[JwtAuthenticationToken] AT --> SC[SecurityContext] SC --> MVC["Controller / Service / @PreAuthorize"]
À gauche — la « requête brute », à droite — « l'utilisateur actuel dans l'application ». Toute notre leçon est un voyage du jeton le long de cet itinéraire.
2. Jeton Bearer et les limites du MVC
Si vous êtes habitué à une application « ordinaire », la main est parfois tentée de faire dans le contrôleur un request.getHeader("Authorization") puis d'analyser la chaîne avec des expressions régulières. Dans les projets d'étude, cela ressemble souvent à « ça marche, non ? ». Mais dans le monde de Spring Security, c'est exactement le cas où « ça marche » ne signifie pas « c'est correct » : vous sortez la logique de sécurité au niveau MVC, vous brisez le mécanisme unique d'erreurs 401/403 et vous transformez la sécurité en un ensemble de décisions aléatoires dans les contrôleurs.
L'idée correcte est la suivante : les contrôleurs et services doivent travailler non pas avec l'en-tête, mais avec le résultat de l'authentification. L'en-tête est une donnée d'entrée pour la couche de sécurité. Et c'est précisément pourquoi le chemin intégré commence à fonctionner avant même d'arriver dans le MVC, à l'intérieur des filtres.
Pour que ce soit très concret, voici à quoi ressemble une requête « sur le terrain » :
# -i : afficher les en-têtes de réponse (utile pour voir les 401/403)
curl -i \
# Transmettre le jeton Bearer dans l'en-tête standard Authorization
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
# Endpoint protégé (si authenticated() est requis, le jeton doit être dans chaque requête)
http://localhost:8080/api/me
Notez un détail important : si vous avez conçu un système sans état, « se connecter une fois puis se promener sans jeton » n'est plus possible. La magie navigateur des cookies/sessions n'aide pas ici. Chaque requête qui nécessite authenticated() doit apporter un jeton Bearer — sinon, il n'y aura pas d'utilisateur actuel.
3. BearerTokenAuthenticationFilter : extraction du jeton
Le chemin JWT intégré commence par un filtre au nom très parlant : BearerTokenAuthenticationFilter. Il fait exactement deux choses, et c'est en cela qu'il est magnifique : il extrait le jeton de la requête et déclenche l'authentification, sans essayer d'être « tout-en-un ».
Au niveau logique, cela ressemble à ceci (pseudo-code simplifié pour voir l'idée, sans discuter des détails de l'API) :
// 1) Récupérer le jeton de la requête (généralement depuis Authorization: Bearer ...)
String token = bearerTokenResolver.resolve(request);
if (token != null) {
// 2) « Empaqueter » le jeton brut dans une demande Authentication (pas encore fiable)
Authentication authRequest = new BearerTokenAuthenticationToken(token);
// 3) Soumettre la demande à AuthenticationManager : il trouvera le Provider approprié et vérifiera le jeton
Authentication authResult = authenticationManager.authenticate(authRequest);
// 4) Mettre le résultat (Authentication déjà authentifié) dans le SecurityContext de la requête en cours
SecurityContextHolder.getContext().setAuthentication(authResult);
}
// 5) Toujours continuer la chaîne de filtres : ensuite, il sera décidé si authenticated() est nécessaire ou si l'endpoint est public
chain.doFilter(request, response);
Il est important de comprendre ici le « comportement sans jeton ». Si la requête ne contient pas d'en-tête Authorization ou n'a pas Bearer ..., le filtre n'est pas obligé d'échouer. Il laisse simplement passer la requête. Et ensuite, authorizeHttpRequests décidera quoi faire : si l'endpoint est public — c'est bon ; s'il nécessite authenticated() — vous obtiendrez 401.
Un autre détail important est que le filtre n'est pas obligé de « deviner » d'où prendre le jeton. Cela est géré par BearerTokenResolver (par défaut, il lit l'en-tête Authorization). Et c'est précisément l'endroit où Spring Security dit : « ne forçons pas chacun à écrire le même code à la main ».
4. AuthenticationManager et JwtAuthenticationProvider
À ce stade, il est utile de se rappeler le vieux mantra du cours : le filtre ne vérifie pas lui-même les identifiants. Il ne fait que « soumettre une demande » d'authentification et la transmet à AuthenticationManager. Pour username/password, nous avions DaoAuthenticationProvider. Pour le modèle JWT, il y a son propre fournisseur : JwtAuthenticationProvider.
Du point de vue du modèle mental, tout est très similaire :
- le filtre forme l'« Authentication d'entrée » (pas encore fiable),
- AuthenticationManager sélectionne le fournisseur qui comprend ce type d'Authentication,
- le fournisseur renvoie l'« Authentication de sortie » (déjà authentifié).
Dans le monde JWT, l'objet « d'entrée » contient généralement la chaîne brute du jeton, et la « sortie » devient JwtAuthenticationToken.
Il est utile de fixer cela avec un petit tableau pour ne pas s'embrouiller :
| Étape | Quel objet | Lui faisons-nous confiance ? | À quoi sert-il ? |
|---|---|---|---|
| Avant authentification | BearerTokenAuthenticationToken | Non | Transmettre le jeton à AuthenticationManager |
| Après décodage | Jwt | Oui (si signature/validité correctes) | Stocker les claims sous une forme pratique |
| Final | JwtAuthenticationToken | Oui | Donner à l'application l'utilisateur actuel + les autorités |
Important : c'est bien JwtAuthenticationToken qui se trouvera dans SecurityContext. Et c'est lui que verront @PreAuthorize, hasAuthority(...), Authentication auth dans le contrôleur et toutes les autres mécaniques de sécurité.
5. JwtDecoder : confiance dans le jeton
Avant JwtDecoder, le jeton n'est qu'une chaîne. Vous pouvez la lire, la transmettre ou même analyser les parties base64, mais vous ne pouvez pas faire confiance à son contenu. JwtDecoder fait deux choses essentielles : il transforme le JWT en un objet Jwt avec des claims et vérifie que l'on peut vraiment se fier à ce jeton — la signature correspond, la durée de vie n'est pas expirée, le format n'est pas corrompu.
Ici, seule la frontière de responsabilité est importante : le décodeur est responsable de la confiance dans le jeton, pas des droits d'accès. Si la signature, la durée ou le format ne passent pas la vérification, la requête n'atteindra pas le contrôleur et ne se transformera pas en utilisateur actuel.
Un point pratique distinct : si l'endpoint de connexion signe le jeton avec une clé, mais que JwtDecoder est configuré avec une autre, vous obtiendrez un éternel « jeton invalide » et 401. Ce n'est pas que « Spring Security fait des caprices », c'est qu'il dit honnêtement : « la signature ne correspond pas — je ne fais pas confiance ».
6. JwtAuthenticationConverter : utilisateur et droits
Après JwtDecoder, nous avons un objet Jwt — décodé et vérifié. Mais l'application a généralement besoin non pas d'un simple ensemble de claims, mais d'une chose compréhensible : un Authentication qui a un name et des authorities. C'est le rôle de JwtAuthenticationConverter.
Il prend le Jwt, transforme les claims en un ensemble de GrantedAuthority et assemble le JwtAuthenticationToken final. Il est important de ne pas confondre les deux entités : Jwt est le contenu du jeton, JwtAuthenticationToken est l'authentification avec laquelle travaillent ensuite @PreAuthorize, hasAuthority(...), Authentication auth dans le contrôleur et les autres mécaniques de sécurité.
Pour l'instant, il suffit de retenir une frontière : le décodeur est responsable de la confiance dans le jeton, le convertisseur est responsable de la manière dont les claims deviennent un utilisateur avec un ensemble de droits. Ainsi, la situation « le jeton est valide, mais toujours 403 » concerne le plus souvent déjà le mapping claims → autorités, et non la vérification de la signature.
7. SecurityContext en mode stateless
Une « douleur fantôme » très fréquente dans le monde JWT ressemble à ceci : « Je me suis connecté ! Pourquoi la requête suivante ne se souvient-elle pas de moi ? » Dans le modèle avec session, la réponse était : « parce que tu as un JSESSIONID et le serveur se souvient de l'état ». Dans le modèle sans état, la réponse est différente : « parce que SecurityContext n'est pas conservé entre les requêtes ».
Dans le chemin JWT intégré, SecurityContext est créé/rempli pour la durée du traitement d'une requête spécifique, généralement stocké dans un thread-local via SecurityContextHolder, et nettoyé après la formation de la réponse. C'est important non seulement en théorie : cela explique directement pourquoi le jeton doit être transmis dans chaque requête et pourquoi un SecurityContext « accidentellement conservé quelque part » est un bogue et une vulnérabilité potentielle.
Cette mécanique peut être représentée commodément ainsi :
sequenceDiagram participant C as Client participant FS as "Security Filter Chain" participant SC as SecurityContextHolder participant MVC as "Controller/Service" C->>FS: "Request (+ Bearer token)" FS->>SC: setAuthentication(JwtAuthenticationToken) FS->>MVC: call controller/service MVC->>SC: read current user / authorities MVC-->>FS: return response FS->>SC: clearContext() FS-->>C: Response
Le sens est que SecurityContext est le « contexte du traitement en cours », et non une « mémoire de l'utilisateur pour toujours ». Dans un modèle sans état, la mémoire réside dans le jeton côté client, et non dans la session serveur.
8. Accès à l'utilisateur actuel dans le code
Lorsque la chaîne de sécurité a terminé son travail, les contrôleurs et services obtiennent une image déjà prête : « voici l'utilisateur actuel, voici ses droits ». Et ici, nous avons un bonus agréable du chemin intégré : vous pouvez obtenir non seulement un Authentication abstrait, mais aussi des objets JWT concrets sans analyse manuelle des en-têtes.
La manière la plus « transparente » est d'accepter JwtAuthenticationToken dans la méthode :
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeNameController {
@GetMapping("/api/me/name")
String name(JwtAuthenticationToken auth) {
// auth est déjà l'Authentication « final » mis dans SecurityContext
// getName() correspond souvent (mais pas toujours) au subject (sub)
return auth.getName();
}
}
Si vous avez besoin des claims du jeton, il est pratique d'obtenir Jwt comme principal :
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeSubController {
@GetMapping("/api/me/sub")
String sub(@AuthenticationPrincipal Jwt jwt) {
// jwt est le jeton décodé et validé avec tous les claims
return jwt.getSubject(); // par exemple « alice »
}
}
Les autorités vivent à l'intérieur de JwtAuthenticationToken, et ce sont elles que vérifient ensuite hasAuthority(...), hasRole(...) et @PreAuthorize. Si une requête arrive avec un jeton valide mais se heurte à 403, le premier réflexe n'est pas de regarder dans le contrôleur ou dans l'en-tête, mais comment les claims ont été transformés en autorités au sein de la chaîne d'authentification.
9. Erreurs typiques lors de l'utilisation du JWT intégré
Erreur n°1 : Lire l'en-tête Authorization dans le contrôleur et parser le jeton manuellement.
Cela semble rapidement être une « solution simple », mais en réalité vous retirez Spring Security du processus : les erreurs cessent d'être uniformes, @PreAuthorize ne voit pas l'utilisateur, et une partie des endpoints vit selon des règles différentes, une autre selon d'autres. Le chemin intégré est justement bon car il transfère l'analyse/validation dans la couche de sécurité.
Erreur n°2 : Penser que JwtDecoder « attribue les droits » et s'étonner du 403.
JwtDecoder concerne la confiance dans le jeton (signature, durée de vie) et l'obtention des claims. Les autorités apparaissent plus tard, à l'étape de JwtAuthenticationConverter. Ainsi, la situation « le jeton est valide, mais les droits sont insuffisants » concerne presque toujours le convertisseur et le mapping des claims, et non le décodeur.
Erreur n°3 : Confondre Jwt et JwtAuthenticationToken.
Jwt est les « données du jeton », tandis que JwtAuthenticationToken est l'Authentication final avec lequel travaille Spring Security. Si vous attendez Jwt à un endroit et utilisez Authentication à un autre, tout en les considérant logiquement comme « la même chose », des casts étranges et des ClassCastException imprévisibles commencent.
Erreur n°4 : S'attendre à pouvoir faire la requête suivante sans jeton après une première réussie.
Dans le modèle sans état, SecurityContext ne vit que pendant le traitement d'une seule requête et est nettoyé après la réponse. Si la requête suivante n'apporte pas de jeton Bearer — du point de vue du serveur, vous êtes à nouveau anonyme. Ce n'est pas un « bogue » ni « Spring Security a oublié l'utilisateur », c'est le concept même du sans état.
Erreur n°5 : Logger l'intégralité du jeton Bearer « pour le débogage ».
L'envie est compréhensible : « je veux juste jeter un coup d'œil ». Mais le jeton Bearer est essentiellement la clé de la porte : celui qui vole la chaîne, entre. Dans les logs, surtout dans les environnements d'équipe/CI, ces chaînes ont tendance à « fuir accidentellement ». Pour le débogage, il suffit généralement de logger sub, jti (si présent) et le fait de l'erreur, mais pas tout le jeton.
GO TO FULL VERSION