1. Utilisateur actuel et identifiant de confiance
Quand on commence à écrire une API REST, on a très envie de tout faire « symétriquement » : puisqu'il y a GET /api/users/{id}, alors PATCH /api/users/{id} semble logique. Mais dès que des opérations self apparaissent, comme « mettre à jour mon profil » ou « voir mes données », la symétrie se transforme en piège : le client commence à dire au serveur qui il est. Or le client est une créature créative. Surtout si ce n'est pas votre frontend, mais un « Pierre avec curl ».
Les règles d'URL et @PreAuthorize savent déjà répondre à la question « quel type d'utilisateur peut en général faire l'action ». Mais dès que l'opération s'intitule « afficher mes données » ou « mettre à jour mon profil », cela ne suffit pas : le système a besoin de savoir non seulement le rôle, mais qui exactement fait la requête à cet instant.
En sécurité, il y a une règle simple qui sauve les nerfs : l'identité de l'utilisateur ne doit pas être tirée des paramètres de la requête pour les actions « en son nom ». Si un endpoint signifie « je fais cela en tant qu'utilisateur actuel », alors l'utilisateur actuel doit être déterminé non pas depuis l'URL, la query ou le body, mais depuis le contexte de sécurité que Spring Security a déjà assemblé après l'authentification.
Imaginez que vous créez un endpoint « mettre à jour mon profil » et que vous acceptez userId depuis le body :
// userId depuis le request-body — source NON fiable pour les self-opérations.
// Cet exemple est présenté comme un anti-pattern : le client peut fournir un id étranger.
public record UpdateProfileRequest(Long userId, String displayName) { }
C'est un peu comme si à l'entrée du bureau, le gardien demandait : « Votre badge ? » — et vous répondiez : « Je suis moi-même le gardien, dans mon JSON il est écrit que je suis le directeur ». Formellement, vous avez « transmis » quelque chose, mais on ne peut pas lui faire confiance.
C'est pourquoi il est important de comprendre où se trouve la source de vérité concernant l'utilisateur actuel, et d'apprendre à le récupérer de deux façons correctes : via @AuthenticationPrincipal (élégamment à l'entrée du contrôleur) et via SecurityContextHolder (quand on est déjà plus profond dans le code et que le paramètre de méthode n'est pas disponible).
2. Modèle mental : SecurityContext → Authentication → principal
Si vous n'avez pas en tête un schéma simple « où vit l'utilisateur », vous allez soit traîner SecurityContextHolder dans chaque deuxième classe, soit (pire) commencer à faire confiance à userId depuis la requête. Alors prenons très calmement et humainement le temps de fixer ce qui se passe dans une application servlet / Spring MVC classique avec Spring Security, quand la requête a déjà passé l'authentification.
Au cœur de Spring Security, il y a une idée centrale : pendant le traitement de la requête, le système forme un SecurityContext, et y stocke une Authentication. Authentication est une « enveloppe » contenant les informations : qui est l'utilisateur (principal), quels sont ses droits (authorities), et s'il est authentifié ou non. Ensuite, Spring MVC peut « injecter » cette information dans les contrôleurs, et les services (si nécessaire) peuvent la lire.
flowchart TD
A[Requête HTTP] --> B[SecurityFilterChain]
B --> C[Authentification établie]
C --> D[SecurityContext peuplé]
D --> E[Méthode du contrôleur]
D --> F["SecurityContextHolder.getContext()"]
Autrement dit, l'utilisateur actuel n'apparaît pas parce que le contrôleur a extrait quelque chose du body de la requête, mais parce que les filtres Spring Security (plus AuthenticationManager/AuthenticationProvider, qui participent à la vérification de l'utilisateur) ont déjà fait leur travail et déposé le résultat dans SecurityContext.
Encore un point important : principal n'est pas nécessairement votre entité JPA. Dans Spring Security, principal est généralement un objet qui implémente UserDetails (ou du moins contient les champs de sécurité nécessaires). Il doit être relativement compact et sûr : un minimum de données, aucune « histoire de vie de l'utilisateur » et, s'il vous plaît, pas de mot de passe en clair (même en hashé, mieux vaut ne pas le trimballer sans raison).
Et maintenant, passons à la pratique : comment récupérer ce principal.
3. @AuthenticationPrincipal dans le contrôleur
Quand un étudiant voit SecurityContextHolder pour la première fois, il ressent souvent une joie étrange : « Oh ! On peut récupérer l'utilisateur de n'importe où ! » Et ensuite commence la saison de la chasse au global state. Mais au niveau du contrôleur, nous avons une manière bien plus lisible et « polie » : @AuthenticationPrincipal. Il rend la dépendance à l'utilisateur actuel explicite directement dans la signature de la méthode, ce qui rend le code plus facile à lire, tester et maintenir.
Mentalement, c'est comme un argument de méthode @RequestBody, mais au lieu du body de la requête, Spring MVC vous injecte le principal depuis le SecurityContext. Et c'est génial, car dans le code on voit immédiatement : « cet endpoint travaille au nom de l'utilisateur actuel ». Pas d'appels cachés à un holder global, pas de surprises. Si un jour vous oubliez d'où vient userId dans le service, l'IDE ne vous aidera pas. Mais la signature du contrôleur, oui.
AppUserPrincipal minimal
On a envie de fourrer dans principal tout : le profil, la couleur préférée, les traumatismes d'enfance et la liste des brouillons. Mais principal n'est pas un « paquet de données pour le frontend », c'est une représentation de sécurité de l'utilisateur. Pour l'accès « owner-based », nous avons besoin d'identifiants stables, et généralement ce sont userId (de la base de données) plus username (ou email, selon votre choix d'identifiant de connexion).
Un exemple minimal d'un tel objet pourrait ressembler à ceci :
// Principal — c'est "qui est l'utilisateur" dans les termes du contexte de sécurité.
// Ici se trouve le userId de confiance, issu de l'authentification, pas de la requête client.
public class AppUserPrincipal {
private final Long userId;
private final String username;
public AppUserPrincipal(Long userId, String username) {
// On ne garde que les champs minimum nécessaires à l'identification.
this.userId = userId;
this.username = username;
}
public Long getUserId() { return userId; }
public String getUsername() { return username; }
}
Dans un vrai projet, principal est souvent « caché » à l'intérieur de votre UserDetails (que retourne CustomUserDetailsService). Mais pour comprendre, cette idée nous suffit : à l'intérieur de principal vit un userId de confiance, que nous transmettrons ensuite pour les vérifications « owner ».
Pour faire le lien avec le matériel déjà vu : si vous avez créé un CustomUserDetailsService, c'est là que vous décidez quel objet deviendra principal. En version simplifiée, cela ressemble à peu près à ceci :
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
class AppUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) {
// On charge l'utilisateur par son identifiant de connexion (généralement username/email).
UserAccount a = userAccountRepository.findByUsername(username).orElseThrow();
// Important : c'est ici qu'on peut "intégrer" le userId dans principal/UserDetails,
// pour que la vérification owner fonctionne ensuite avec un id numérique issu de la BDD.
return new AppUserDetails(a.getId(), a.getUsername(), a.getPasswordHash(), a.getRoles());
}
}
Ce qui compte ici, ce n'est pas l'implémentation, mais le fait : vous pouvez faire en sorte que principal connaisse le userId. Et alors les règles « owner-based » deviennent non pas une devinette « à qui est ce brouillon ? », mais une comparaison honnête entre authorId et currentUserId.
Exemple : GET /api/me sans paramètres superflus
Dans notre projet, il y a un endpoint GET /api/me. Son sens est justement de ne pas accepter de userId. C'est un self-endpoint : « montre-moi qui je suis ». Il illustre donc parfaitement @AuthenticationPrincipal.
Créons une réponse simple :
// DTO de réponse, qu'on peut renvoyer au client en toute sécurité.
public record MeResponse(Long id, String username) { }
Et le contrôleur :
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
@GetMapping("/api/me")
MeResponse me(@AuthenticationPrincipal AppUserPrincipal principal) {
// principal est déjà fiable : il a été formé par la couche de sécurité.
// Nous n'acceptons ici aucun userId provenant de l'URL/body/query.
return new MeResponse(principal.getUserId(), principal.getUsername());
}
}
Notez l'effet agréable : le contrôleur ne se soucie absolument pas de ce qui est arrivé dans la requête. Il n'a pas besoin de lire l'en-tête Authorization, de parser des cookies, ni d'accepter un paramètre userId. Tout cela a déjà été fait par la couche de sécurité, et le contrôleur utilise simplement le résultat — l'utilisateur actuel en qui le système a confiance.
Il existe aussi une astuce pratique si vous ne voulez pas traîner tout le principal dans la méthode, mais seulement un champ spécifique. @AuthenticationPrincipal accepte une expression SpEL, et cela rend parfois le code très concis :
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/me/id")
Long myId(@AuthenticationPrincipal(expression = "userId") Long userId) {
// La méthode reçoit uniquement le champ souhaité depuis principal, sans dépendre de toute la classe.
return userId;
}
Ce n'est pas une technique obligatoire, mais elle est utile : le contrôleur obtient exactement ce dont il a besoin et dépend moins de la classe concrète de principal.
Utilisateur non authentifié
Les débutants demandent souvent : « Et si principal est null ? » C'est une bonne question, car elle vous oblige à penser au-delà du happy path. En réalité, si un endpoint est protégé par authenticated(), une requête anonyme n'atteindra généralement pas le contrôleur d'une manière qui vous oblige à vérifier manuellement principal. La sécurité agira et retournera un 401 (via AuthenticationEntryPoint, que nous avons déjà abordé).
Mais il y a deux nuances à retenir. Premièrement, si vous avez accidentellement ouvert un endpoint avec la règle permitAll(), principal pourrait être null ou vous pourriez obtenir un principal anonyme (selon la configuration de l'authentification anonyme). Deuxièmement, si vous écrivez du code qui peut être appelé en dehors d'une requête web (par exemple, un service interne), SecurityContext pourrait même être vide.
Par conséquent, la recommandation pratique est la suivante : au niveau des zones API, il est préférable de maintenir des règles claires au niveau de la requête, afin de ne pas obliger chaque contrôleur à faire une mini-version de la sécurité. Et utilisez @AuthenticationPrincipal comme une « entrée » pratique vers l'utilisateur actuel là où l'accès est déjà garanti par les règles.
4. SecurityContextHolder dans les services
Aussi agréable que soit @AuthenticationPrincipal, il vit dans le monde des contrôleurs. Ensuite commence la vraie vie : les services appellent d'autres services, des opérations métier apparaissent, des utilitaires généraux, et il n'est pas toujours pratique de passer principal en paramètre tout au long de la chaîne d'appels. C'est là qu'intervient SecurityContextHolder — un accès direct au SecurityContext (et donc à Authentication) depuis n'importe quel endroit du code.
Mais il est facile de commettre le « péché du débutant » : commencer à appeler SecurityContextHolder partout. Le code fonctionnera… jusqu'à ce que vous essayiez de le tester, de le réutiliser, ou jusqu'à ce que vous rencontriez un endroit où le contexte est inopinément vide. Nous devons donc considérer SecurityContextHolder comme un « couteau tranchant » : il est nécessaire, mais il ne faut pas l'utiliser pour éplucher les pommes de terre, ouvrir le courrier et se gratter le dos en même temps.
L'idée principale : si l'utilisateur actuel est nécessaire dans un service, il est souvent préférable d'y passer le userId de confiance en paramètre depuis le contrôleur. Et laissez SecurityContextHolder comme solution de secours, quand autrement ce serait vraiment peu pratique.
CurrentUserService comme wrapper
Pour ne pas disperser les appels à SecurityContextHolder.getContext() dans tout le projet, on crée généralement un petit service adaptateur qui devient le point unique de « contact » du code métier avec le contexte de sécurité. Cela améliore considérablement la lisibilité et réduit le risque qu'un certain repository se mette soudainement à « lire l'utilisateur actuel » (alors qu'un repository ne devrait pas du tout être au courant).
Exemple minimal :
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
class CurrentUserService {
Long userId() {
// On récupère l'Authentication depuis le SecurityContext courant (scope request dans le modèle servlet).
Authentication a = SecurityContextHolder.getContext().getAuthentication();
// Important : ici on attend notre type de principal, déposé lors de l'authentification.
// Dans le code réel, on renforce généralement ces endroits avec des vérifications et un traitement plus soigné.
AppUserPrincipal p = (AppUserPrincipal) a.getPrincipal();
// On retourne le userId de confiance pour les vérifications owner-based.
return p.getUserId();
}
}
Oui, il y a un cast de type — et ce n'est pas idéal. Dans le code réel, vous ajouterez des vérifications et un traitement plus soigné pour le cas où principal n'est pas du bon type. Mais ce qui compte ici, c'est l'idée architecturale : un seul CurrentUserService vaut mieux que 37 endroits avec SecurityContextHolder.
Et oui, c'est toujours une dépendance à la sécurité. Mais elle est localisée. Nous ne l'avons pas cachée « à l'intérieur du repository », nous avons créé un adaptateur explicite.
Données provenant de Authentication
Parfois, authentication.getName() suffit. Parfois, on a besoin d'un rôle/authority. Parfois, c'est précisément userId dont on a besoin, car dans la base de données ContentItem.authorId est un nombre, et on ne veut pas le comparer avec un username textuel (et c'est d'ailleurs incorrect).
Voici un petit extrait qui montre ce qui se trouve dans Authentication, et pourquoi getName() n'est pas un « identifiant universel pour tout » :
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
// Authentication est tirée du contexte de sécurité, assemblé par les filtres avant le contrôleur.
Authentication a = SecurityContextHolder.getContext().getAuthentication();
// getName() est généralement = identifiant de connexion (username/email), pas l'id de la BDD.
System.out.println(a.getName()); // par exemple : "alice"
// userId — identifiant applicatif (généralement Long de la base), pratique pour les vérifications owner.
System.out.println(((AppUserPrincipal) a.getPrincipal()).getUserId()); // par exemple : 42
Et voici une observation très importante : getName() est généralement un identifiant de connexion (username/email), tandis que userId est un identifiant applicatif dans votre base de données. Ils ne sont pas obligés de coïncider et ne coïncident presque jamais.
Si vous devez prendre une décision basée sur les droits, vous pouvez regarder les authorities :
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
Authentication a = SecurityContextHolder.getContext().getAuthentication();
// Authorities — ce sont les "droits/rôles" de la couche de sécurité. Ils sont déjà disponibles sans requêtes supplémentaires à la BDD.
boolean canPublish = a.getAuthorities().stream()
.anyMatch(x -> x.getAuthority().equals("draft:publish"));
Ici, nous ne menons pas la décision métier jusqu'au bout. Ce qui compte ici, c'est autre chose : Authentication est l'endroit où se trouve déjà le minimum de sécurité, et il est accessible sans requêtes à la BDD.
5. getName() et userId
L'une des causes les plus fréquentes de futurs bugs dans l'accès « owner-based » est la confusion sur « comment exactement l'utilisateur est identifié ». Pour Spring Security au niveau de base, le username (ou email) est important, car c'est généralement par lui que se fait le chargement de UserDetails et qu'il participe au flux standard username/password. Donc authentication.getName() retourne le plus souvent ce par quoi l'utilisateur « s'est présenté à la connexion ».
Mais dans le modèle métier du projet, nous avons UserAccount.id (clé numérique) et de nombreuses entités qui référencent le propriétaire via ownerUserId ou authorId. Et là, c'est bien userId qui compte. C'est stable : le username peut théoriquement changer (dans un projet pédagogique on ne fait souvent pas de changement de username, mais en réalité cela arrive), alors que l'id reste le même.
La conclusion pratique est simple : si vous comparez le propriétaire d'un objet (par exemple, draft.authorId) avec l'utilisateur actuel, vous devez comparer le même type d'identifiant. Si votre authorId est un Long, alors l'utilisateur actuel est mieux représenté aussi comme Long userId. Cela rend la vérification owner honnête et rapide : sans requêtes supplémentaires, sans « et si le username a changé ».
C'est pourquoi il est si utile, une fois (dans CustomUserDetailsService), de placer userId dans principal. Alors dans le contrôleur ou le service, vous récupérez principal.getUserId() et l'utilisez en toute confiance comme identifiant de confiance. Et authentication.getName() reste utile pour les logs, le diagnostic et certaines réponses UI où on veut afficher le « username » à l'utilisateur.
6. Limites des couches : controller/service/repo
Quand on commence à introduire l'utilisateur actuel dans les opérations métier, il y a un risque de « disperser » la sécurité dans toute l'application. C'est comme si vous découvriez qu'en Java il y a static, et vous décidiez que tout va devenir static. Oui, ça marchera… mais vous n'en serez pas heureux. Il est donc important de séparer les zones de responsabilité : où @AuthenticationPrincipal est approprié, où SecurityContextHolder est acceptable, et où la sécurité ne devrait pas du tout être présente.
Il est pratique de fixer cela dans un tableau simple, pour que le cerveau n'ait pas à inventer de nouvelles règles à chaque fois :
| Couche | Façon normale d'obtenir l'utilisateur actuel | Pourquoi c'est OK | Ce qu'il vaut mieux éviter |
|---|---|---|---|
| Controller | @AuthenticationPrincipal | Dépendance explicite dans la signature, lisible | Faire confiance à userId depuis la requête pour les self-opérations |
| Service | Passer currentUserId en paramètre ou utiliser CurrentUserService | Moins d'état global, plus facile à tester | Appels à SecurityContextHolder dans chaque méthode « juste parce que possible » |
| Repository | Ne pas connaître l'utilisateur actuel du tout | Le repository concerne les données, pas la sécurité | SecurityContextHolder à l'intérieur du repository (c'est presque toujours une mauvaise idée) |
Pourquoi un repository ne devrait-il pas avoir connaissance de la sécurité ? Parce que le repository est une couche d'infrastructure d'accès aux données. S'il commence à dépendre de « qui est l'utilisateur actuel » , vous obtenez des dépendances cachées, des effets étranges dans les tests, et une complexité de réutilisation. Aujourd'hui ce sera « pratique », et dans une semaine vous découvrirez qu'il est impossible d'utiliser normalement le repository en arrière-plan, dans une opération d'administration ou dans un test, parce qu'il a soudainement besoin d'un SecurityContext.
Si vous devez « n'afficher que mes brouillons », le point architectural normal est le service : il reçoit currentUserId (de confiance) et appelle le repository avec ce paramètre. À peu près comme ceci :
import org.springframework.stereotype.Service;
@Service
class DraftQueryService {
List<ContentItem> myDrafts(Long currentUserId) {
return contentRepository.findAllByAuthorId(currentUserId);
}
}
Cette approche semble un peu plus « verbeuse » que d'utiliser SecurityContextHolder à l'intérieur du repository, mais elle reste honnête : la méthode dit explicitement qu'elle a besoin de currentUserId. Et donc, elle est facile à tester et à lire.
7. Erreurs typiques lors du travail avec l'utilisateur actuel
Erreur n°1 : prendre userId pour des self-opérations depuis l'URL, la query ou le body.
C'est l'erreur la plus dangereuse, car elle crée une vulnérabilité presque imperceptiblement. Vous pouvez penser que « tout est protégé par authenticated() », mais un utilisateur authentifié peut très bien essayer de mettre à jour le profil d'un autre ou de récupérer le brouillon d'un autre, simplement en fournissant un autre id. Pour les self-endpoints, la source de vérité est le SecurityContext, pas les paramètres de la requête.
Erreur n°2 : utiliser SecurityContextHolder comme une variable globale et le traîner dans chaque classe.
Techniquement, cela fonctionne, mais architecturalement cela transforme l'application en bouillie : les dépendances deviennent cachées, les tests fragiles, et la lecture du code ressemble à une quête « trouve d'où vient userId ». Il est bien mieux de garder SecurityContextHolder à un ou deux endroits (par exemple, CurrentUserService) ou de passer currentUserId de confiance en paramètre.
Erreur n°3 : appeler SecurityContextHolder dans un repository.
Le repository doit être aussi « stupide » et prévisible que possible : des paramètres en entrée — une requête aux données en sortie. Quand le repository commence à lire « l'utilisateur actuel », il devient implicitement lié au contexte web et aux filtres de sécurité. Cela se manifestera presque à coup sûr par des problèmes étranges dans les tests et lors de la réutilisation du code.
Erreur n°4 : caster aveuglément principal vers un type personnalisé sans contrôler ce qu'il contient.
Dans différents scénarios, principal peut être de types différents, et en cas de requête non authentifiée, il peut même être null/anonyme. Si vous faites (AppUserPrincipal) authentication.getPrincipal() sans comprendre le contexte, vous pouvez obtenir une ClassCastException au moment le plus inattendu. La protection minimale est de garder ces casts en un seul endroit et de les faire en connaissance de cause.
Erreur n°5 : confondre authentication.getName() et userId.
getName() retourne généralement un identifiant de connexion textuel (par exemple, username), tandis que userId est un identifiant numérique dans la base de données. Pour les vérifications owner, vous avez presque toujours besoin de userId pour le comparer avec authorId/ownerUserId. La confusion conduit à des vérifications « qui ne marchent jamais », où tout semble correct mais on compare des entités différentes avec des types différents.
GO TO FULL VERSION