Click here for english version
On est forcément passé un jour ou l’autre sur un projet impliquant Spring Security : le framework dont on dit beaucoup de bien, et qu’en tant que débutant, on espère ne pas avoir à configurer.
M’étant auparavant arraché quelques cheveux sur le sujet lors de projets personnels devant me servir de tutoriels, je me suis penché plus en détail sur la question, pour découvrir qu’en réalité, le principe de fonctionnement de Spring Security n’est pas plus compliqué qu’un autre. Il faut simplement partir du principe que ce n’est pas un framework à traiter par dessus la jambe pour cocher une case “bonne pratique” dans le projet.
Mots clefs et principes de bases
Spring Security ne sécurise pas d’infrastructures réseaux ou de machines, mais des applications : il intervient dans l’interaction entre un utilisateur et un programme, ou entre deux programmes. Cette interaction est orchestrée par la Dispatcher Servlet de Spring, qui permet d’adresser les requêtes aux différents controllers de l’application. En résumé, Spring Security ne fait qu’ajouter des traitements à cette orchestration, par le biais de Servlet Filters. L’ensemble des Servlet Filters constitue la Filter Chain de Spring Security.
L’action de la filter chain est centrée autour de 2 concepts fondamentaux :
- l’authentification : celui qui utilise l’application doit être identifié par un couple username/password.
- les autorisations : tous les utilisateurs n’ont pas nécessairement accès aux mêmes fonctionnalités. Par exemple, un utilisateur non administrateur ne doit pas pouvoir modifier de compte autre que le sien.
La filter chain placée en amont de la Dispatcher Servlet permet la vérification de l’authentification et des autorisations de l’utilisateur avant que la requête n’atteigne les controllers.
On prêtera attention au faux-ami en anglais : l’erreur 401 (unauthorized) survient lorsque l’utilisateur n’est pas authentifié, et l’erreur 403 (forbidden) survient lorsque l’utilisateur authentifié n’est pas autorisé à consulter la page demandée.
Filter Chain par défaut
Lors du lancement d’une application implémentant Spring Security, l’un des messages visible dans la console commence par :
2020–02–25 10:24:27.875 INFO 11116 — — [ main] o.s.s.web.DefaultSecurityFilterChain : Creating filter chain: any request […]
Cette ligne énumère la liste des filtres implémentés par défaut par Spring Security. Ci-dessous se trouve une liste ordonnée des différents filtres constituant la filter chain par défaut et un résumé de leurs fonctions (Spring Security v5.4.2). Si cette liste permet de mieux comprendre la façon dont fonctionne Spring Security, il n’est pas nécessaire d’en avoir connaissance pour appréhender la suite de l’article.
- WebAsyncManagerIntegrationFilter : fournit l’intégration entre le SecurityContext et le WebAsyncManager de Spring permettant d’alimenter le SecurityContext lors d’une requête.
- SecurityContextPersistenceFilter : place les informations obtenues du SecurityContextRepository dans le SecurityContextHolder, utilisé par la suite lors du process d’authentification qui nécessite un SecurityContext valide.
- HeaderWriterFilter : permet d’ajouter des headers à la réponse en cours de confection. Utile pour ajouter certains headers qui renforcent la sécurité au niveau du navigateur, comme X-Frame-Options, X-XSS-Protection et X-Content-Type-Options (cf. le paragraphe sur la protection générale).
- CsrfFilter : applique une protection contre les attaques de type Cross-Site Request Forgery en utilisant un token, usuellement stocké dans la HttpSession. Il est souvent demandé aux développeurs d’invoquer ce filtre avant toute requête susceptible de modifier l’état de l’application (usuellement les requête de type POST, PUT, DELETE et parfois OPTIONS).
- LogoutFilter : intervient lorsqu’une requête de déconnexion est reçue, et délègue les différents processus de déconnexion à divers lougoutHandlers (nettoyage du contexte de sécurité, invalidation de la session, redirection…).
- UsernamePasswordAuthenticationFilter : analyse une soumission de formulaire d’authentification, qui doit fournir un couple username/password. Ce filtre est activé par défaut sur l’URL /login.
- DefaultLoginPageGeneratingFilter : construit une page d’authentification par défaut, à moins d’être explicitement désactivé. C’est pour cette raison qu’une page de login apparaît lors de l’activation de Spring Security, avant même que le développeur ne code une page personnalisée.
- DefaultLogoutPageGeneratingFilter : construit une page de déconnexion par défaut, à moins d’être explicitement désactivé.
- BasicAuthenticationFilter : Vérifie la présence dans la requête reçue d’un header de type basic auth, et le cas échéant, essaie d’authentifier l’utilisateur avec le couple username/password récupéré dans ce header.
- RequestCacheAwareFilter : Vérifie dans le cache des requêtes si une requête passée correspond à la requête en cours pour en accélérer le traitement.
- SecurityContextHolderAwareRequestFilter : agrémente chaque requête d’un wrapper offrant diverses fonctionnalités, notamment en ce qui concerne l’authentification : récupération de l’utilisateur, vérification qu’il est bien authentifié, récupération de ses rôles, possibilité d’authentification via l’Authentication Manager, possibilité de déconnexion via les logout handlers, maintient de cohérence du Security Context dans les différents threads…
- AnonymousAuthenticationFilter : fournit un objet de type Authentication au Security Context Holder s’il n’en a aucun.
- SessionManagementFilter : vérifie qu’un utilisateur est authentifié depuis le début de la requête, et effectue le cas échéant des actions relatives à la session, comme la vérification de la présence de plusieurs logins concurrents.
- ExceptionTranslationFilter : Gère les AccessDeniedException et AuthenticationException levées par la filterChain. Ce filtre est nécessaire car il fait le lien (la traduction) entre les exceptions Java et les réponses http, permettant de maintenir la viabilité l’IHM en cas d’erreur.
- FilterSecurityInterceptor : Effectue les vérifications d’autorisations basées sur les rôles de l’utilisateur connecté.
Cette liste de filtres n’est ni plus ni moins que la réelle substance de Spring Security, permettant de prendre en compte les concepts d’authentification, d’autorisation, et offrant quelques features supplémentaires destinées à protéger des attaques les plus communes (cf. le paragraphe sur la protection générale). Il ne reste au développeur qu’à configurer la façon dont ces filtres vont s’intégrer à l’application : quelles URLs/méthodes protéger, quelles données inscrites en base utiliser pour authentifier un utilisateur…
Focus sur l’authentification
Il faut distinguer 3 scénarios d’authentification :
- Les données des utilisateurs (identifiant + mot de passe) sont stockés dans une base de données à laquelle le développeur a accès : c’est le cas le plus courant, et celui qui sera détaillé ici.
- L’application n’a pas directement accès à ces informations et doit passer par exemple par un service REST tiers pour l’authentification. Ce cas s’applique par exemple dans le cadre d’une utilisation d’Atlassian Crowd. On retiendra qu’il se traite comme le premier cas, à l’exception de cette couche intermédiaire d’interrogation du service d’authentification qu’il faut ajouter.
- L’authentification est effectuée via OAuth2 (cas d’un “login with Google” par exemple). Ce cas demande nettement plus d’explications et ne sera pas détaillé ici.
En considérant que les mots de passe des utilisateurs sont cryptés dans la base de donnée (même la flemme la plus draconienne ne dispense pas le développeur de cette contrainte), 2 beans doivent être déclarés pour que l’authentification soit opérationnelle : une implémentation de l’interface UserDetailsService, et un PasswordEncoder.
- UserDetailsService : l’implémentation de cette interface doit comporter une méthode renvoyant un objet de type UserDetails à partir d’un simple identifiant d’utilisateur. Cet objet contient à minima le couple username/password, ainsi que généralement la liste des rôles (c’est à dire les autorisations) de l’utilisateur. Il est tout à fait possible d’utiliser/d’étendre les implémentations toutes faites fournies par Spring Security.
- PasswordEncoder : permet de spécifier quel algorithme d’encryption utiliser sur les mots de passe. L’algorithme par défaut de Spring Security est BCrypt. Il est tout à fait possible d’utiliser différents algorithmes selon les utilisateurs, option sur laquelle nous ne nous attarderons pas.
La déclaration de ces beans est simplissime :
Comment se met en place l’authentification à partir de ces 2 beans? Grâce au Basic Authentication Filter cité plus haut :
- Extraction du couple username/password d’un header de la requête, valorisé grâce au formulaire de connexion rempli par l’utilisateur (cette étape est automatique).
- Utilisation du bean userDetailsService pour récupérer (en base de données, à partir du username renseigné par l’utilisateur) les informations nécessaires à la confection d’un objet UserDetails. Cet objet contient le mot de passe crypté de l’utilisateur.
- Encryption automatique du mot de passe renseigné dans le formulaire de connexion, et comparaison avec le mot de passe contenu dans l’objet UserDetails. L’utilisateur est authentifié si la comparaison révèle que les mots de passe sont identiques.
Focus sur l’autorisation
Spring Security distingue deux objets relatifs à l’autorisation :
- Les Authorities : dans sa forme la plus basique, une Authority n’est qu’une simple chaîne de caractères désignant une responsabilité : “user”, “ADMIN”, “grandDictateurEnChef”…
- Les Roles : un Role n’est ni plus ni moins qu’une Authority précédée du préfixe “ROLE_”
Mais alors, quelle différence entre les deux? Aucune réelle distinction n’est à faire, et l’utilisation de l’un ou l’autre est interchangeable. On peut cependant noter que de façon générale, un Role correspond à une Authority qui se démarque par son caractère “bateau”, “normalisé”, “standard” : il est fréquent de trouver un Role “ROLE_ADMIN” correspondant à l’Authority “ADMIN”, mais l’on trouvera rarement un Role “ROLE_pas_tout_a_fait_chef_mais_presque_la_preuve_jai_une_cravate”.
La chaîne de caractère correspondant à une autorisation est contenue dans une instance de l’interface GrantedAuthority. L’implémentation la plus utilisée de cette interface est la SimpleGrantedAuthority, mais il en existe d’autre qui permettent une adaptation plus aisée aux différents types d’autorisations que l’on peut trouver (comme par exemple LdapAuthority, OAuth2UserAuthority, ou encore SwitchUserGrantedAuthority). Ces autorisations devant être enregistrées pour chaque utilisateur, la façon de les stocker doit s’adapter à leur volume et au nombre de permissions différentes : simple colonne dans la table des utilisateurs, ou table spécialement consacrée, etc… Dans la suite de cet article, nous partirons du principe que la table des utilisateurs comporte une colonne “autorisations”.
La récupération des chaînes de caractères depuis la base de données et leur identification auprès de Spring Security en tant qu’autorisations est généralement effectuée dans le userDetailsService :
- Récupération d’un User en interrogeant la base de données
- Récupération de l’attribut “autorisations” de ce User, qui est alors mappé en liste de SimpleGrantedAuthorities.
Application de l’authentification et des autorisations
Paramétrage de l’authentification via UserDetails…check. Stockage/récupération des autorisations en base de données pour chaque utilisateur…check. Reste maintenant à voir comment dire à Spring Security quelles URLs protéger, et avec quelles restrictions : certaines URLs seront accessibles à tout le monde (connecté ou non), certaines page ne seront visibles que pour les utilisateurs connectés, et certaines pages ne seront de surcroît accessibles qu’aux utilisateurs disposant d’autorisations particulières.
La classe dans laquelle renseigner tous ces détails répond à 3 critères principaux :
- elle hérite de WebSecurityConfigurerAdapter => elle surcharge (entre autres) la méthode configure(HttpSecurity).
- elle est annotée @Configuration => cette annotation indique que le principal but de la classe est la déclaration et l’instanciation de beans (c’est à dire d’objets qui doivent être gérés par Spring).
- elle est également annotée @EnableWebSecurity => l’ajout de cette annotation à la précédente est la façon de dire à Spring Security “tire ta configuration de ce WebSecurityConfigurerAdapter là”.
Dans la méthode configure(HttpSecurity), le choix des protections derrière lesquelles sécuriser nos URLs s’effectue par le biais du DSL (Domain Specific Language) de Spring Security, qui a l’avantage d’être relativement transparent :
- http : il s’agit de l’objet HttpSecurity donné en paramètre, celui que nous voulons configurer.
- authorizeRequests : la suite de la configuration concerne des requêtes vers des URLs, définies (dans cet exemple) par des “antMatchers”. Un antMatcher permet de désigner un pattern selon les règles principales suivantes : ? = n’importe quel caractère non nul; * = n’importe quel caractère (possiblement nul); ** = n’importe que nombre de répertoires dans le chemin de l’URL; {nomVariable:[a-z]+} = la regex [a-z]+, stockée dans une variable “nomVariable”.
- hasRole(“ADMIN”) : l’URL est accessible à un utilisateur authentifié, qui a le rôle ADMIN, correspondant à la chaîne de caractères “ROLE_ADMIN”. Le contrôle hasRole(“ADMIN”) est strictement équivalent au contrôle hasAuthority(“ROLE_ADMIN”).
- hasAuthority : l’URL est accessible à un utilisateur authentifié, qui bénéficie de l’autorisation PRESQUE_ADMIN, correspondant à la chaîne de caractères “PRESQUE_ADMIN”.
- hasAnyAuthority : l’URL est accessible à un utilisateur authentifié, qui bénéficie d’au moins l’une des 2 autorisations suivantes : PRESQUE_ADMIN (chaîne de caractères “PRESQUE_ADMIN”), ou ROLE_ADMIN (chaîne de caractères “ROLE_ADMIN”). Le contrôle hasAuthority(“ROLE_ADMIN”) est strictement équivalent au contrôle hasRole(“ADMIN”).
- authenticated : l’URL est accessible à n’importe quel utilisateur authentifié, indépendamment de ses rôles/autorisations.
- permitAll : l’URL est accessible sans authentification nécessaire (souvent, seule la page d’authentification est accessible à tout le monde).
- anyRequest : définit le comportement par défaut de toute URL non précisée précédemment.
- and : la configuration des requêtes est terminée, mais nous souhaitons configurer d’autres aspects de Spring Security (voir points 10 et 11).
- formLogin : l’authentification est autorisée via le formulaire de login.
- httpBasic : l’authentification est autorisée via un header BasicAuth.
Pour en finir avec la configuration, il est important de mentionner que l’utilisation de la méthode “access” en association avec le Spring Expression Language (SpEL) permet de personnaliser au mieux (et en une seule instruction) l’accès à certaines URLs :
Dans la méthode access ci-dessus, Spring Security :
- vérifie que l’utilisateur est authentifié et a le rôle “ADMIN”
- vérifie que l’adresse IP d’où émane la requête est 192.168.1.0/24
- transmet la variable “adminName” de l’URL à la méthode checkAdminName de la classe monBeanPerso pour effectuer un contrôle personnalisé codé par le développeur.
Défense en profondeur
Jusqu’à présent, la sécurité que nous avons paramétrée ne concerne que les pages web de l’application et leur accès, ce qui est tout a fait suffisant dans la plupart des cas. Pour renforcer encore la sécurité, il existe la notion de “defense in depth”, que je propose audacieusement de traduire par “défense en profondeur” : il s’agit de contrôler l’accès aux méthodes que l’on peut trouver dans les @Controllers, les @Components, les @Services, les @Repositories, et autres beans Spring. Cette approche est mise en place par le biais d’annotations affectant les méthodes publiques des beans. Pour rendre possible cette sécurité supplémentaire, il est nécessaire de l’autoriser explicitement dans la classe annotée @Configuration que nous avons survolée dans la partie précédente, en ajoutant l’annotation @EnableGlobalMethodSecurity :
- prePostEnabled : permet d’utiliser les annotations @PreAuthorize et @PostAuthorize (voir les exemples ci-après).
- securedEnabled : permet d’utiliser l’annotation @Secured (voir les exemples ci-après).
- jsr250Enabled : permet d’utiliser l’annotation @RolesAllowed (voir les exemples ci-après).
En pratique, voici les effets de ces annotations :
La méthode est accessible uniquement si l’utilisateur authentifié a le rôle “ADMIN”. Ces 2 annotations ont le même effet et prennent en paramètre un rôle ou une autorisation. Leur différence réside dans le fait que @Secured est une annotation spécifique à Spring, fournie par la dépendance spring-security-core, tandis que @RolesAllowed est une annotation standardisée fournie par la dépendance javax.annotation-api.
Les annotation @PreAuthorize et @PostAuthorize sont considérées comme plus puissantes que les précédentes, car elles peuvent accepter n’importe quelle expression SpEL aussi bien qu’un rôle ou une autorisation. @PreAuthorize effectue le contrôle avant d’entrer dans la méthode, tandis que @PostAuthorize l’effectue après l’exécution de la méthode, ayant la possibilité d’en modifier le résultat.
Deux mots sur la protection contre les principaux exploits
1. CSRF
Certaines requêtes modifient l’état de l’application : elle correspondent généralement aux mot-clefs POST, PUT, DELETE et parfois OPTIONS. Ces requêtes sont parfois soumises à l’application, en provenance d’une page qui n’a en apparence rien à voir, de façon malveillante et imperceptible pour l’utilisateur. Afin d’éviter ce genre d’attaque, il est possible d’utiliser le CsrfFilter de Spring Security (voir la partie sur la Filter Chain) en ajoutant la méthode csrf() à la configuration de la sécurité web :
Il est a noter que dans le cadre d’une application reposant sur une API Rest et un client Js, le paramétrage csrf est plus délicat : le jeton garantissant l’authenticité de la requête est fournit par l’API et doit être conservé (et envoyé avec chaque requête) dans le client Js. Un exemple illustrant ce genre de configuration est disponible sur cette page.
2. Headers
Spring Security fournit des protections contre les attaques les plus communes. Ces protections sont principalement fournies par des Response-headers, dont l’implémentation par défaut est considérée comme bonne pratique, mais qui peut néanmoins être personnalisée selon les besoins de l’application. La liste des principaux Response-headers et de leurs fonctions est présentée ci-dessous :
- cache control : le comportement par défaut de Spring Security est de ne conserver aucune information en cache. Cela permet par exemple d’éviter qu’un utilisateur mal intentionné consulte une page confidentielle en profitant du login stocké en cache d’un collègue qui aurait oublié de verrouiller son poste. Les headers contrôlant le cache sont par défaut les suivants :
Si l’application définit ses propres headers contrôlant le contenu du cache, ceux de Spring Security sont désactivés par défaut.
- content type options : historiquement, les navigateurs étaient paramétrés pour améliorer l’expérience utilisateur en détectant automatiquement le format de certaines ressources (images, textes, etc…) lorsqu’il n’était pas précisé. Un utilisateur malveillant était par conséquent capable en postant un contenu ambivalent (par exemple un texte qui est aussi un script Js valide) de faire exécuter du code par l’application cible. Spring Security désactive la détection automatique du format en ajoutant ce header :
- http strict transport security : omettre “https” dans une requête dans la barre d’adresse, même si le site cible redirige automatiquement vers une adresse https, expose à une attaque de type “man in the middle”. Spring Security dispose d’un header ajoutant automatiquement “https” à une liste de sites :
- x-frame-options : autoriser son site à être ajouté à une frame peut soulever des problèmes de sécurité. Par exemple, l’utilisation (certes fastidieuse) de CSS permettrait de faire cliquer l’utilisateur sur un élément masqué, causant alors un comportement non prévu. Ce type d’attaque est connue sous le nom de ClickJacking. Une façon d’éviter le clickJacking est de désactiver le rendu des pages au sein des frames. Spring Security utilise ce contournement avec le header suivant :
- x-xss-protection : lorsque le navigateur détecte et filtre une attaque de type XSS (Cross Site Scripting), le comportement par défaut du navigateur varie. Certains tentent de modifier le code à afficher afin de conserver au maximum le rendu de la page tout en évitant le contenu malveillant, d’autres bloquent le rendu. Par défaut, Spring Security bloque le contenu perçu comme malveillant en utilisant ce header :
- clear-site-data : Clear Site Data est un mécanisme permettant le nettoyage de certaines données conservées dans le navigateur (par exemple les cookies). L’utilisation d’un header comme celui présenté ci-dessous est une bonne pratique de nettoyage à mettre en place lors d’une déconnexion :
Conclusion
Spring Security est un sujet sur lequel il est toujours possible d’en apprendre un peu plus, d’aller un peu plus en profondeur. Une connaissance encyclopédique du sujet n’est cependant pas nécessaire pour mettre en place une sécurité raisonnable sur une application. Voici les quelques points à conserver sur une fiche pense-bête pour s’y retrouver aisément :
- Spring Security est pour l’essentiel un enchainement de filtres à appliquer (ou non) et à paramétrer.
- La sécurité apportée à l’application repose sur le duo authentification / autorisation
- La classe annotée @EnableWebSecurity est celle dans laquelle sont paramétrés les filtres à appliquer et les URLs à protéger : c’est elle qui permet de comprendre en un clin d’oeil quelles sont les sécurités mises en oeuvre dans une application.
Sources de l’article :
Documentation Spring :
- spring security architecture
- spring security reference documentation
- spring security reference expression language
- spring security reference headers
Articles & blogs :