1. Problème : le token dans le contrôleur
Lorsque nous écrivons pour la première fois un endpoint de connexion, la main est tentée de tout faire au même endroit : accepter le JSON, vérifier le login/mot de passe, rassembler les claims, signer le JWT, renvoyer la réponse. Cela semble rapide et « pragmatique », surtout si vous n'avez pas encore développé d'allergie aux contrôleurs de 300 lignes. Mais c'est exactement cette approche qui rend le code de sécurité fragile et difficile à maintenir.
Imaginons un contrôleur « collé » typique. Je montre volontairement un anti-exemple — non pas pour que vous le répétiez, mais pour que vous sentiez l'odeur des problèmes à venir :
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
class AuthController {
@PostMapping("/login")
Object login(@RequestBody Object request) {
// Anti-exemple : le contrôleur ne doit pas contenir toute la logique de sécurité.
// 1) ici serait "vérifier le mot de passe" (zone de responsabilité d'AuthenticationManager)
// 2) ici serait "rassembler les claims" (zone de responsabilité de TokenService)
// 3) ici serait "signer le JWT"
// 4) renvoyer une réponse JSON au client
return "token"; // Mauvais signal : "chaîne magique" au lieu d'un modèle de résultat
}
}
À ce stade, un débutant dit souvent : « Et alors, ça marche quand même. » Ça marche — tant que la tâche est petite. Mais ensuite la vie normale s'installe : la composition des claims change, le TTL change, on a besoin de journaliser l'événement de connexion, on veut tester la délivrance du token séparément, différents types de tokens apparaissent, il faut gérer les erreurs plus proprement. Et soudain, le contrôleur se transforme en couteau suisse qui sait tout faire, sauf rester lisible.
Dans une API stateless, il est particulièrement important que la couche HTTP reste fine. Le contrôleur doit être une porte : « on entre — on sort ». Mais la décision « quel token délivrer à un utilisateur authentifié » est une opération métier distincte de la couche de sécurité.
2. Rôles : AuthenticationManager et TokenService
Dans un monde stateless, il est pratique de penser ainsi : AuthenticationManager est le « contrôle des passeports », et TokenService est la « délivrance du laissez-passer pour le territoire ». Le contrôle des passeports ne doit pas imprimer les laissez-passer, et l'imprimerie des laissez-passer ne doit pas décider qui est un vrai citoyen et qui est venu avec une impression depuis Paint. La séparation semble évidente dans la vie – et pourtant elle est très souvent oubliée dans le code.
Pour éviter toute confusion, il est utile de fixer les rôles sous forme de tableau :
| Composant | Question principale | Ce qu'il reçoit en entrée | Ce qu'il produit en sortie |
|---|---|---|---|
| AuthenticationManager | « Cet utilisateur peut-il être considéré comme authentifié ? » | credentials (login/mot de passe sous forme de requête Authentication) | Authentication authentifié ou exception |
| TokenService | « Quel access token délivrer à un utilisateur déjà authentifié ? » | Authentication réussi (sans mot de passe en tant qu'objet métier) | modèle de token (chaîne + durée de vie) |
Et maintenant – un schéma pour que le cerveau ne voie pas seulement les mots :
flowchart TD %% Le client appelle l'endpoint de connexion C["Client"] -->|"POST /api/auth/login"| AC["AuthController"] %% Le contrôleur délègue l'orchestration au service AC --> AS["AuthService"] %% Spring Security vérifie les credentials et renvoie Authentication ou une erreur AS --> AM["AuthenticationManager"] AM --> AS %% Après une authentification réussie, délivre un access token par un service séparé AS --> TS["TokenService"] TS --> AS AS --> AC AC -->|"JSON response"| C
Remarquez une idée importante : TokenService ne doit pas accepter le mot de passe en clair. Jamais. Pas de String password « pour une seconde », pas de LoginRequest, pas de HttpServletRequest. Ce n'est pas un caprice d'architecte, c'est une hygiène de base : moins il y a d'endroits dans le code qui « voient » le mot de passe, moins il y a de chances de le journaliser accidentellement, de le sauvegarder ou de le transmettre ailleurs.
Et un autre point : TokenService ne décide pas si le mot de passe est correct. Il part du principe que Authentication est déjà réussi, donc le mot de passe a déjà été vérifié par les mécanismes standard de Spring Security (via DaoAuthenticationProvider, UserDetailsService, PasswordEncoder et la prise en compte des états du compte).
3. Modèle de résultat : IssuedAccessToken
Lorsque le token est délivré, nous devons quelque part stocker le résultat. On pourrait renvoyer directement une String, mais cela conduit rapidement à des « chaînes magiques » : à un endroit on a besoin du TTL, à un autre du token type, ailleurs on veut ajouter issuedAt, et ailleurs encore il y aura deux tokens. Il est donc plus pratique de créer un petit modèle interne.
Dans notre projet Secure Content Platform API, nous allons créer un objet de résultat de délivrance de token. Pour que le code soit court et compréhensible, on peut utiliser un record (en Java 25, c'est un outil normal et quotidien pour les « classes de données »).
package com.example.securecontent.security.jwt;
/**
* Résultat interne de la délivrance d'un access token.
* Important : ce n'est pas un DTO HTTP, mais un contrat entre la couche de sécurité et le service applicatif.
*/
public record IssuedAccessToken(
String value, // Contenu du JWT sous forme de chaîne
long expiresInSeconds // Durée de vie du token (TTL) en secondes
) { }
Il est important de comprendre que IssuedAccessToken est un contrat interne entre les couches, et non un « format obligatoire de réponse HTTP ». Pourquoi c'est fondamental :
Le contrôleur et la couche web en général vivent dans le monde HTTP : @RequestBody, @ResponseStatus, contrats JSON et erreurs. Alors que TokenService doit vivre dans le monde de la mécanique de sécurité et de la décision métier « délivrer un token ». Si nous obligeons TokenService à renvoyer directement un AuthResponse (DTO pour le client), nous lions la couche de sécurité à la forme web, et tout refactoring cosmétique de l'API affectera ensuite les entrailles de la sécurité.
Un modèle interne, c'est comme un emballage dans l'entrepôt : il peut différer de la vitrine du magasin. L'entrepôt doit fonctionner efficacement et de manière stable, tandis que la vitrine peut changer en fonction des exigences du client.
4. Interface TokenService
Maintenant que nous avons un modèle de résultat, nous pouvons décrire le contrat du service lui-même. Un principe est important ici : le contrat doit être minimal, mais exprimer le sens. Nous n'écrivons pas une « moissonneuse-batteuse universelle de génération de tokens pour tous les temps », nous écrivons un service compréhensible pour le projet actuel et le niveau actuel du cours.
L'interface minimale ressemble à ceci :
package com.example.securecontent.security.jwt;
import org.springframework.security.core.Authentication;
public interface TokenService {
/**
* Délivre un access token basé sur une authentification déjà réussie.
* Important : ici arrive Authentication après vérification du mot de passe.
*/
IssuedAccessToken issueAccessToken(Authentication authentication);
}
Pourquoi l'entrée est-elle Authentication et non UserAccount, UserDetails ou String username ? Parce que Authentication est déjà le résultat « canonique » du travail de Spring Security. Il contient le nom d'utilisateur (getName()), les authorities, et il prend déjà en compte tout ce que nous avons construit avant : les états du compte, les rôles, les règles de chargement de l'utilisateur depuis la base de données.
Pourquoi cela est utile en pratique, surtout pour un débutant. Si vous essayez de passer dans TokenService quelque chose de « personnel », vous commencerez rapidement à faire un mapping manuel et à vous mettre d'accord sur ce qui est la vérité : ici roles, là authorities, ici username, là id, là accountNonLocked… En conséquence, vous dupliquez le modèle de sécurité dans votre code et augmentez le risque de désynchronisation.
Alors que Authentication est ce que Spring Security utilise déjà pour prendre des décisions. Donc, s'appuyer sur lui est le chemin le plus économique.
Pour mieux sentir les limites de responsabilité, imaginez deux questions que vous posez au code :
- Si la question est « le mot de passe est-il correct ? » ou « l'utilisateur est-il bloqué ou non ? » — c'est le domaine de AuthenticationManager.
- Si la question est « que mettre dans le token ? quel TTL ? quel format ? » — c'est le domaine de TokenService.
Voilà. Ne mélangez pas.
5. AuthService comme orchestrateur
Dans notre application, il y aura un endpoint POST /api/auth/login. Mais même si nous ne discutons pas encore de son contrat JSON, nous pouvons déjà préparer un point architectural correct : un service qui relie les étapes du login flow. D'habitude, je l'appelle « orchestrateur » : il ne joue pas de tous les instruments à la fois, mais dit qui doit entrer et quand.
La structure minimale d'un tel service :
package com.example.securecontent.auth;
import com.example.securecontent.security.jwt.TokenService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
// AuthenticationManager est responsable de la vérification des credentials et de la délivrance de Authentication
private final AuthenticationManager authenticationManager;
// TokenService est responsable de la délivrance du access token après authentification réussie
private final TokenService tokenService;
public AuthService(AuthenticationManager authenticationManager, TokenService tokenService) {
// DI rend les dépendances explicites et testables
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
}
Remarquez deux détails qui semblent mineurs, mais qui en réalité évitent des problèmes futurs.
Premièrement, AuthService reçoit AuthenticationManager, et non un quelconque UserRepository ou PasswordEncoder. Cela nous maintient dans le cadre du modèle correct : les credentials sont vérifiés par Spring Security.
Deuxièmement, AuthService reçoit TokenService, et non une « classe utilitaire qui génère des tokens ». C'est déjà une architecture où la délivrance de token est une responsabilité distincte.
Vous pouvez demander : « Pourquoi un service supplémentaire alors qu'on pourrait faire contrôleur → authenticationManager → tokenService ? » La réponse est simple : parce que le contrôleur ne doit pas connaître les étapes internes, et parce que dans un projet réel, il y a presque toujours des actions supplémentaires autour de la connexion (par exemple, audit de l'événement de connexion, limitations de tentatives, normalisation du login). L'orchestrateur est l'endroit où ces étapes vivent, sans gonfler le contrôleur.
6. Packages pour TokenService
Quand le projet est petit, on a l'impression que « les packages ne sont pas importants ». Quand le projet vit au moins un mois, les packages deviennent soudainement une navigation sans laquelle vous commencez à « chercher le service token avec Ctrl+Shift+F et des prières ». C'est pourquoi il est préférable de placer le code là où on l'attend.
Nous avons déjà fixé la structure du projet comme package-by-feature avec une zone de sécurité séparée. Donc, pour ce jour, les endroits naturels sont :
- com.example.securecontent.security.jwt — pour TokenService, IssuedAccessToken et les futures utilitaires JWT au niveau sécurité.
- com.example.securecontent.auth — pour AuthService et les endpoints web de la zone auth (contrôleurs et DTO), car « auth » est une zone applicative du produit.
Pourquoi ne pas mettre TokenService dans auth ? Parce que la délivrance de token fait partie de l'infrastructure de sécurité, et elle sera nécessaire non seulement « dans le contrôleur de connexion », mais aussi en tant que composant distinct qui doit rester propre, avec un minimum de dépendances vis-à-vis de la couche web.
Et une autre idée importante : TokenService doit être un bean Spring, et non une public final class JwtUtils { ... } avec des méthodes statiques. Dès que vous aborderez la configuration du secret, du TTL et d'autres paramètres, vous voudrez immédiatement l'injection de dépendances (DI) et la configuration. De plus, un bean est plus facile à tester et à remplacer.
Un petit conseil issu de la pratique : si vous voyez que la logique de sécurité est dispersée entre controller, service, util, config sans zone claire, cela signifie presque toujours que dans quelques semaines, vous vous organiserez vous-même une quête « pourquoi le token est délivré d'une manière et vérifié d'une autre ».
7. Erreurs typiques lors de l'extraction de TokenService
Erreur n°1 : générer le token directement dans le contrôleur.
Cela commence généralement par « je vérifie rapidement une idée », et se termine par le contrôleur devenant le centre de l'univers : il contient les claims, le TTL, la signature, la gestion des erreurs, la journalisation et quelques TODO supplémentaires. Ce code est difficile à tester, difficile à modifier et facile à casser accidentellement. La bonne habitude est de garder le contrôleur fin et la délivrance du token dans TokenService.
Erreur n°2 : passer le mot de passe en clair ou LoginRequest à TokenService.
Si TokenService accepte le mot de passe, il devient complice de l'authentification, alors qu'il ne devrait pas l'être. De plus, vous augmentez la surface de fuite : le mot de passe peut accidentellement se retrouver dans le journal, dans une exception, dans une impression de débogage. Il est beaucoup plus sûr et architecturalement plus honnête de passer à TokenService uniquement Authentication réussi, où le mot de passe n'est plus une « donnée métier » que vous continuez à trimballer dans le code.
Erreur n°3 : faire de TokenService une classe utilitaire statique.
Tant que vous générez un « token-chaîne à partir de rien », un util statique semble pratique. Mais dès que des paramètres apparaissent (secret, TTL, issuer), vous commencerez soit à les coder en dur, soit à passer les paramètres via une dizaine de méthodes, soit à traîner @Value dans l'util et à vous casser la tête. Un bean Spring est ici plus simple et plus correct : la configuration et les dépendances vivent via DI, et le service reste testable.
Erreur n°4 : mélanger le modèle interne du token et la réponse HTTP.
Si TokenService renvoie un DTO de réponse (AuthResponse), il commence à connaître le contrat API et la couche web. Ensuite, vous modifiez le format de réponse (par exemple, vous voulez renvoyer un champ tokenType ou changer le nom de accessToken), et vous devez toucher à la couche de sécurité. Un objet interne comme IssuedAccessToken résout le problème : TokenService reste interne, et le contrôleur / service de niveau API emballe cela dans le format JSON requis.
Erreur n°5 : journaliser le token en entier « pour le débogage ».
Cela semble anodin : « je suis en local ». Mais les habitudes migrent en production plus vite qu'on ne le pense, et les logs ont tendance à atterrir dans des systèmes centralisés. Même dans un projet d'apprentissage, il est préférable d'acquérir la norme : ne pas imprimer le token en entier. Si c'est vraiment nécessaire, loggez les 10 à 15 premiers caractères et une indication que le token a été délivré, sans fuite du contenu.
GO TO FULL VERSION