Spring Framework

Les bases de Spring Security : filter chain, authentification et autorisation

Intermédiaire8 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Spring Security est une chaîne de filtres servlet configurée par un bean SecurityFilterChain. À l'issue de ce guide, vous comprendrez comment une requête est authentifiée puis autorisée, comment déclarer des utilisateurs et encoder correctement les mots de passe, comment protéger des URL et des méthodes avec le DSL lambda et @PreAuthorize, ce que fait la protection CSRF et quand la désactiver, et comment ce même modèle s'étend aux API authentifiées par token.

Ce que vous allez apprendre

  • Expliquer le rôle de DelegatingFilterProxy, de FilterChainProxy et des différents filtres de sécurité
  • Configurer une SecurityFilterChain avec le DSL lambda pour le form login, HTTP Basic et les API stateless
  • Fournir les utilisateurs via UserDetailsService et stocker les mots de passe avec un DelegatingPasswordEncoder
  • Autoriser les requêtes par motif d'URL et les méthodes avec @PreAuthorize, en distinguant rôles et authorities
  • Décrire ce contre quoi protège la protection CSRF et décider quand on peut la désactiver

La sécurité est le sujet que la plupart des applications ratent, non parce qu'elle est difficile à ajouter, mais parce qu'elle est difficile à ajouter partout. Spring Security règle ce problème en s'insérant devant chaque requête sous forme de chaîne de filtres servlet, et devant les méthodes choisies sous forme d'intercepteur AOP : authentification et autorisation sont déclarées une seule fois et appliquées de façon cohérente. Ce guide couvre Spring Security 6.x, utilisé avec Spring Framework 6 sur Java 17 ou plus, avec le modèle de configuration Java qui a remplacé WebSecurityConfigurerAdapter ; OAuth 2.0 et les formats de token eux-mêmes sont traités dans le parcours Sécurité.

Architecture : la filter chain

Le conteneur de servlets ne connaît qu'un seul filtre : DelegatingFilterProxy, enregistré sous le nom springSecurityFilterChain. Il existe pour qu'un filtre géré par le conteneur puisse déléguer à un bean Spring. Ce bean, c'est FilterChainProxy, qui contient une ou plusieurs instances de SecurityFilterChain, chacune associée à un motif de requête. Pour une requête donnée, la première chaîne qui correspond exécute sa liste ordonnée de filtres — une quinzaine pour une configuration classique avec form login. Ceux qu'il faut connaître :

FiltreResponsabilité
SecurityContextHolderFilterCharge le SecurityContext (depuis la session, par défaut) dans SecurityContextHolder pour la durée de la requête, puis le vide
CsrfFilterRejette les requêtes qui modifient l'état sans token CSRF valide
UsernamePasswordAuthenticationFilterTraite le POST du formulaire de login
BasicAuthenticationFilterTraite l'en-tête Authorization: Basic
BearerTokenAuthenticationFilterTraite Authorization: Bearer pour les resource servers
ExceptionTranslationFilterTransforme une AuthenticationException en challenge (redirection vers le login, 401) et une AccessDeniedException en 403
AuthorizationFilterApplique les règles de authorizeHttpRequests — le dernier filtre avant votre application

L'authentification se déroule en deux temps. Un filtre construit à partir de la requête un token Authentication non authentifié — nom d'utilisateur et mot de passe, bearer token — et le confie à un AuthenticationManager. L'implémentation standard, ProviderManager, essaie chaque AuthenticationProvider jusqu'à ce que l'un d'eux prenne en charge le token ; DaoAuthenticationProvider charge l'utilisateur via un UserDetailsService, vérifie le mot de passe avec un PasswordEncoder et renvoie une Authentication complète portant la liste des GrantedAuthority de l'utilisateur. Le filtre la place dans le SecurityContextHolder et, pour les applications à session, la persiste dans la HttpSession afin que les requêtes suivantes n'aient pas à repasser par le login.

L'autorisation intervient plus tard, et deux fois : AuthorizationFilter vérifie les règles d'URL puis, si la sécurité des méthodes est activée, un intercepteur AOP vérifie le @PreAuthorize de la méthode appelée. Tous deux consultent un AuthorizationManager avec l'Authentication courante.

Configurer une SecurityFilterChain

Depuis Spring Security 5.7, la configuration est un bean, et non une sous-classe. Avec Spring Boot, la présence de spring-boot-starter-security suffit à enregistrer le filtre ; dans un WAR Spring MVC classique, il faut en plus déclarer une classe qui étend AbstractSecurityWebApplicationInitializer pour que le conteneur prenne en compte DelegatingFilterProxy.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/about", "/css/**", "/js/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/orders/**").hasAuthority("orders:write")
                .anyRequest().authenticated())
            .formLogin(form -> form
                .loginPage("/login")           // your own GET /login view; POST /login is handled by the filter
                .permitAll())
            .logout(logout -> logout.logoutSuccessUrl("/"))
            .httpBasic(Customizer.withDefaults());   // also accept Basic, for scripts
        return http.build();
    }

    @Bean
    UserDetailsService users(PasswordEncoder encoder) {
        var alice = User.withUsername("alice")
                .password(encoder.encode("correct horse battery staple"))
                .roles("USER", "ADMIN")                          // stored as ROLE_USER, ROLE_ADMIN
                .build();
        var bob = User.withUsername("bob")
                .password(encoder.encode("bob-password-2026"))
                .authorities("ROLE_USER", "orders:write")        // fine-grained: a role plus a permission
                .build();
        return new InMemoryUserDetailsManager(alice, bob);
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

Le style lambda est le seul DSL de Spring Security 7 ; l'ancienne forme chaînée avec .and() a été dépréciée en 6.1. Écrivez donc dès maintenant la forme lambda. Les règles de authorizeHttpRequests sont évaluées dans l'ordre de déclaration et la première qui correspond l'emporte : les motifs spécifiques doivent donc précéder anyRequest(). permitAll fait quand même passer la requête dans la chaîne — l'utilisateur est identifié si une session existe —, alors que web.ignoring() court-circuite entièrement la sécurité et doit être réservé aux ressources réellement statiques.

hasRole("ADMIN") vérifie l'authority ROLE_ADMIN ; hasAuthority("orders:write") vérifie la chaîne littérale. Les rôles sont des groupes à gros grain, les authorities des permissions individuelles ; les systèmes de grande taille stockent généralement les permissions et les déduisent des rôles au moment du login.

Utilisateurs et mots de passe

Le gestionnaire en mémoire ci-dessus est réservé aux démos et aux tests. Les applications en production implémentent UserDetailsService sur leur propre stockage :

@Service
public class AccountUserDetailsService implements UserDetailsService {

    private final AccountRepository accounts;

    public AccountUserDetailsService(AccountRepository accounts) { this.accounts = accounts; }

    @Override
    public UserDetails loadUserByUsername(String username) {
        Account a = accounts.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));   // message is not shown to the client
        return User.withUsername(a.email())
                .password(a.passwordHash())                 // already encoded, e.g. "{bcrypt}$2a$10$…"
                .authorities(a.permissions().stream().map(SimpleGrantedAuthority::new).toList())
                .accountLocked(a.locked())
                .disabled(!a.active())
                .build();
    }
}

JdbcUserDetailsManager et une variante LDAP existent pour les schémas standard. Quel que soit le stockage, les mots de passe doivent être hachés avec un algorithme lent et salé. PasswordEncoderFactories.createDelegatingPasswordEncoder() renvoie un DelegatingPasswordEncoder qui encode en bcrypt par défaut et préfixe chaque hash d'un identifiant — {bcrypt}, {argon2}, {pbkdf2} — pour qu'on puisse changer d'algorithme plus tard tout en continuant à vérifier les anciens hashes. Ne stockez jamais de texte en clair ; {noop} n'existe que pour les tests. Comme la vérification est volontairement lente (bcrypt à la force par défaut prend plusieurs dizaines de millisecondes, voire plus, par contrôle), authentifiez une fois par session plutôt qu'à chaque requête : c'est exactement ce que fait la sécurité à base de session.

Sécurité des méthodes

Les règles d'URL protègent les points d'entrée ; la sécurité des méthodes protège les opérations qui se trouvent derrière, y compris celles atteintes par d'autres chemins — un job planifié, un listener de messages, un second contrôleur. @EnableMethodSecurity (qui a remplacé @EnableGlobalMethodSecurity en 6.0) active @PreAuthorize, @PostAuthorize, @PreFilter et @PostFilter avec le Spring Expression Language :

@Service
public class OrderService {

    @PreAuthorize("hasAuthority('orders:write')")
    public Order place(CreateOrder cmd) { … }

    @PreAuthorize("hasRole('ADMIN') or #customerId == authentication.name")
    public List<Order> ordersOf(String customerId) { … }

    @PostAuthorize("returnObject.customerId == authentication.name or hasRole('SUPPORT')")
    public Order get(long id) { … }
}

Comme il s'agit d'AOP, les règles habituelles des proxies s'appliquent : le contrôle n'a lieu que si l'appel passe par le proxy, pas en cas d'auto-invocation, et un appel refusé lève une AccessDeniedException — que ExceptionTranslationFilter transforme en 403 si elle remonte jusqu'à la couche web. Placez les annotations sur la couche service, là où l'opération métier est définie, plutôt que sur des contrôleurs qui ne font que transmettre l'appel.

Protection CSRF

Une attaque par cross-site request forgery piège le navigateur d'un utilisateur connecté pour lui faire envoyer une requête qui modifie l'état — un POST de formulaire vers votre site depuis une page contrôlée par l'attaquant —, et le navigateur y joint automatiquement le cookie de session. Spring Security s'en défend en exigeant que toute requête non sûre (POST, PUT, PATCH, DELETE) porte un token que la page de l'attaquant ne peut pas lire : le CsrfFilter compare le token présent dans la requête (un champ de formulaire nommé _csrf ou l'en-tête X-CSRF-TOKEN) à celui stocké côté serveur. L'intégration Spring de Thymeleaf et la balise de formulaire JSP ajoutent automatiquement le champ caché ; un client JavaScript le lit dans le cookie XSRF-TOKEN lorsque vous configurez CookieCsrfTokenRepository.withHttpOnlyFalse().

Spring Security 6 a changé deux comportements par défaut : le token est chargé à la demande (seulement quand une requête en a réellement besoin) et la valeur exposée à la requête est masquée à chaque requête par XorCsrfTokenRequestAttributeHandler, le nouveau handler par défaut, pour résister aux attaques de type BREACH. Les deux sont transparents pour les applications à formulaires, et un peu plus délicats pour les single-page apps qui lisent le token dans un cookie ; la section SPA du guide de référence donne la configuration exacte.

Cette protection n'a de sens que lorsque le navigateur s'authentifie automatiquement. Une API authentifiée par un bearer token dans un en-tête y est insensible, c'est pourquoi la configuration stateless ci-dessous la désactive.

API stateless

Un resource server qui valide des bearer tokens à chaque requête n'a besoin ni de session, ni de page de login, ni de CSRF :

@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/**")                              // this chain only for the API
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
            .anyRequest().authenticated())
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))   // validates JWTs against the issuer's JWKS
        .exceptionHandling(e -> e.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()));
    return http.build();
}

Plusieurs chaînes s'ordonnent avec @Order ; la première dont le securityMatcher correspond traite la requête, la chaîne de l'API doit donc passer avant une chaîne web fourre-tout. oauth2ResourceServer().jwt() nécessite les modules spring-security-oauth2-resource-server et spring-security-oauth2-jose ainsi qu'un issuer ou une JWK Set URI ; les scopes du token deviennent des authorities préfixées par SCOPE_, que vous pouvez remapper avec un JwtAuthenticationConverter. Pour décoder un token et voir ses claims pendant le débogage, utilisez le décodeur JWT — jamais pour lui faire confiance : seule la vérification de signature du resource server le permet.

Accéder à l'utilisateur courant

@GetMapping("/me")
public ProfileResponse me(@AuthenticationPrincipal UserDetails user) {
    return profiles.forUsername(user.getUsername());
}

// in a service, without web types
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
boolean admin = auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"));

Le contexte est conservé dans un ThreadLocal pendant toute la durée de la requête. Le travail confié à un autre thread — @Async, un executor, un parallel stream — ne le voit pas, sauf si vous enveloppez l'executor dans un DelegatingSecurityContextExecutor ou que vous passez la stratégie du holder à MODE_INHERITABLETHREADLOCAL.

Erreurs courantes

Placer anyRequest() en premier. Toutes les règles suivantes deviennent inatteignables ; Spring Security 6 lève une exception au démarrage pour les règles déclarées après anyRequest(), mais un motif large /** placé trop tôt masque silencieusement les règles spécifiques.

Préfixer hasRole par ROLE_. hasRole("ROLE_ADMIN") lève une IllegalArgumentException. Utilisez hasAuthority si vous voulez la chaîne littérale.

Encoder les mots de passe au login au lieu de le faire à l'inscription. La vérification passe par PasswordEncoder.matches(raw, encoded) ; ne comparez jamais encode(raw) au hash stocké — les algorithmes salés produisent un résultat différent à chaque appel.

Désactiver le CSRF sur une application à cookie de session « parce qu'il gênait ». Vous venez de rendre possible l'attaque que le filtre empêchait. Corrigez plutôt l'intégration du formulaire ou de la SPA.

Révéler si un nom d'utilisateur existe. Par défaut, UsernameNotFoundException est convertie en erreur générique d'identifiants invalides (hideUserNotFoundExceptions) ; ne revenez pas là-dessus, et gardez sur la page de login le même message pour un utilisateur inconnu et pour un mauvais mot de passe.

Se fier uniquement aux règles d'URL. Un nouveau mapping de contrôleur hors des motifs protégés est ouvert par défaut, à moins que anyRequest().authenticated() ne comble la brèche ; la sécurité des méthodes sur le service rattrape ce que la couche URL laisse passer.

Oublier CORS dans la chaîne de sécurité. Les requêtes preflight OPTIONS traversent la filter chain avant d'atteindre MVC ; appelez http.cors(Customizer.withDefaults()) avec un bean CorsConfigurationSource pour qu'elles soient autorisées.

Points clés à retenir

  • Spring Security est un FilterChainProxy de filtres ordonnés, enregistré via DelegatingFilterProxy ; les filtres d'authentification alimentent SecurityContextHolder, AuthorizationFilter applique les règles d'URL.
  • La configuration passe par des beans SecurityFilterChain et le DSL lambda ; WebSecurityConfigurerAdapter a disparu en 6.0.
  • Les utilisateurs viennent d'un UserDetailsService ; les mots de passe sont stockés avec un DelegatingPasswordEncoder (bcrypt par défaut) et vérifiés avec matches.
  • hasRole ajoute le préfixe ROLE_, hasAuthority non ; @PreAuthorize sur les services protège les opérations quel que soit le point d'entrée, sous réserve des règles des proxies AOP.
  • La protection CSRF compte dès que le navigateur s'authentifie automatiquement ; les API stateless à bearer token la désactivent et passent à SessionCreationPolicy.STATELESS.
  • Le contexte de sécurité est lié au thread ; propagez-le explicitement vers les traitements en arrière-plan.

Sources et lectures complémentaires

Questions fréquentes

Qu'est devenu WebSecurityConfigurerAdapter ?
Il a été déprécié dans Spring Security 5.7 et supprimé dans la 6.0. Au lieu d'étendre une classe de base, vous déclarez un bean SecurityFilterChain qui reçoit un builder HttpSecurity, et un bean UserDetailsService au lieu de redéfinir configure(AuthenticationManagerBuilder). Plusieurs chaînes peuvent coexister, chacune associée à un préfixe d'URL grâce à securityMatcher.
Quelle différence entre hasRole et hasAuthority ?
Les deux comparent avec les chaînes GrantedAuthority portées par l'Authentication. hasRole('ADMIN') ajoute automatiquement le préfixe ROLE_ et vérifie donc l'authority ROLE_ADMIN ; hasAuthority('ROLE_ADMIN') vérifie la chaîne littérale. Utilisez hasRole pour des rôles à gros grain et hasAuthority pour des permissions fines comme orders:write. Ne passez jamais à hasRole une chaîne qui commence déjà par ROLE_ : une exception est levée.
Faut-il désactiver la protection CSRF pour une API REST ?
Seulement si l'API n'est authentifiée par rien de ce que le navigateur envoie automatiquement. Si les clients s'authentifient avec un bearer token dans un en-tête Authorization, une page d'un autre site ne peut pas forger la requête et la protection CSRF n'apporte rien : désactivez-la et passez la politique de session à STATELESS. Si l'API utilise des cookies de session — une single-page app sur le même site, par exemple —, gardez-la activée et utilisez le token repository à base de cookie pour que le JavaScript puisse lire le token.
Comment récupérer l'utilisateur courant dans un contrôleur ou un service ?
Dans un contrôleur, déclarez un paramètre @AuthenticationPrincipal UserDetails ou un java.security.Principal. Dans un service, SecurityContextHolder.getContext().getAuthentication() renvoie l'Authentication courante ; le contexte est lié au thread de la requête par SecurityContextHolderFilter, il n'est donc pas disponible sur d'autres threads, sauf si vous le propagez explicitement.