8. Authentification et autorisation
Deux questions distinctes se posent pour chaque requête :
- qui est l'utilisateur ? C'est l'authentification : il s'est identifié par un login et un mot de passe, et chaque requête suivante doit prouver qu'elle vient de lui ;
- a-t-il le droit d'accéder à cette page ? C'est l'autorisation : dans l'étude de cas, seul un utilisateur de rôle ADMIN gère les médecins et les clients.
Le serveur étant sans état (chapitre 6), la preuve d'identité doit voyager avec chaque requête. On utilise un jeton JWT ([JSON Web Token]) : après la connexion, le serveur fabrique un jeton qui contient l'identité de l'utilisateur et le signe avec une clé secrète. Le jeton est déposé dans un cookie ; le navigateur le renvoie à chaque requête ; le serveur vérifie la signature. L'utilisateur peut lire son jeton, mais pas le modifier : toute modification rend la signature invalide.
Avec [Spring], ces deux questions sont l'affaire de [Spring Security], un projet de la famille [Spring] distinct de [Spring MVC]. [Spring Security] est une chaîne de filtres de servlet (cf. chapitre 7), placée devant le [DispatcherServlet] : chaque requête la traverse avant d'atteindre [Spring MVC].
- l'authentification est l'affaire d'un filtre qui cherche un jeton dans la requête, le vérifie et en tire l'utilisateur authentifié, un objet [Authentication] rangé dans le contexte de sécurité de la requête. Pour les jetons JWT, ce filtre est fourni par la partie « serveur de ressources » ([resource server]) de [Spring Security], dont le nom vient du protocole [OAuth2] : un serveur de ressources est un serveur qui accepte des jetons fabriqués par un autre (ici, par lui-même) ;
- l'autorisation est l'affaire d'un autre filtre, qui applique des règles écrites en [Java] pour chaque URL (« cette adresse est publique », « celle-ci exige un utilisateur authentifié »), et d'annotations posées sur les contrôleurs ([@PreAuthorize], exemple 26).
Les exemples de ce chapitre utilisent un starter de plus : [spring-boot-starter-security-oauth2-resource-server]. Il apporte [Spring Security], sa partie « serveur de ressources », et la bibliothèque [Nimbus JOSE + JWT], qui fabrique et vérifie les jetons. Le hachage des mots de passe ([bcrypt]) fait partie de [Spring Security] : il n'y a pas d'autre bibliothèque à déclarer. [Maven] télécharge ces bibliothèques à la première compilation : il faut donc une connexion Internet.
8.1. Exemple 25 : connexion, jeton, page protégée
| 25-jwt/
├── src/
│ └── main/
│ ├── java/
│ │ └── exemple25/
│ │ ├── auth/
│ │ │ ├── AuthService.java
│ │ │ ├── JwtProprietes.java
│ │ │ └── UtilisateursService.java
│ │ ├── config/
│ │ │ ├── SecuriteConfig.java
│ │ │ └── WebConfig.java
│ │ ├── controllers/
│ │ │ ├── AuthController.java
│ │ │ └── ProfilController.java
│ │ └── Exemple25Application.java
│ └── resources/
│ ├── templates/
│ │ ├── auth/
│ │ │ └── login.html
│ │ ├── profil/
│ │ │ └── profil.html
│ │ └── layout.html
│ └── application.properties
└── pom.xml
|
Le fichier [pom.xml] :
| <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cours.springmvc</groupId>
<artifactId>exemples</artifactId>
<version>1.0</version>
</parent>
<artifactId>exemple25</artifactId>
<dependencies>
<!-- les vues Thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Spring Security, avec l'authentification par jeton JWT (création et vérification des jetons) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
</project>
|
- lignes 15-18 : les vues [Thymeleaf] ;
- lignes 20-23 : [Spring Security] et l'authentification par jeton JWT. Dès que [Spring Security] est présent dans le projet, [Spring Boot] protège par défaut toutes les URL de l'application. C'est la classe de configuration [SecuriteConfig], plus bas, qui fixe nos propres règles.
Le fichier [application.properties] :
| # le port d'écoute du serveur (8080 par défaut)
server.port=5000
# les réponses d'erreur de Spring Boot ne contiennent pas la pile d'appels
spring.web.error.include-stacktrace=never
# la clé qui signe les jetons JWT (au moins 32 caractères), et leur durée de vie
jwt.secret=une-cle-secrete-d-au-moins-32-caracteres-pour-l-exemple-25
jwt.duree-minutes=60
|
- lignes 6-7 : deux propriétés propres à l'application : la clé qui signe les jetons et leur durée de vie. Qui connaît la clé peut fabriquer des jetons valides : en production, elle ne figure pas dans un fichier du projet, elle est fournie par une variable d'environnement ([JWT_SECRET], qui l'emporte sur le fichier) ou un coffre à secrets. L'algorithme utilisé (HMAC-SHA256) exige une clé d'au moins 32 octets (256 bits) : [Nimbus] refuse de signer avec une clé plus courte.
Le fichier [auth/JwtProprietes.java] :
| package exemple25.auth;
import org.springframework.boot.context.properties.ConfigurationProperties;
// les propriétés [jwt.*] de application.properties, rangées dans un record :
// jwt.secret -> secret ; jwt.duree-minutes -> dureeMinutes
@ConfigurationProperties("jwt")
public record JwtProprietes(String secret, int dureeMinutes) {
}
|
- lignes 7-8 : l'annotation [@ConfigurationProperties("jwt")] demande à [Spring Boot] de remplir ce record avec les propriétés qui commencent par [jwt.] : [jwt.secret] va dans le composant [secret], [jwt.duree-minutes] dans le composant [dureeMinutes] (les tirets sont ignorés, les majuscules aussi : c'est la liaison souple de [Spring Boot]). La valeur [60] est convertie en [int] ; une valeur qui ne serait pas un nombre arrêterait l'application dès le démarrage, avec un message clair. Le record devient un bean, que l'on reçoit par injection comme n'importe quel service. Il est déclaré par l'annotation [@EnableConfigurationProperties] de [SecuriteConfig] (plus bas).
On aurait pu lire chaque propriété avec l'annotation [@Value("${jwt.secret}")] ; le record regroupe les propriétés d'un même sujet, et leur donne un type.
Le fichier [auth/UtilisateursService.java] :
| package exemple25.auth;
import java.util.List;
import java.util.Optional;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
// les utilisateurs, en mémoire (le chapitre suivant les mettra dans une base)
@Service
public class UtilisateursService {
public record Utilisateur(String login, String nom, String role, String hachageMotDePasse) {
}
private final List<Utilisateur> utilisateurs;
// PasswordEncoder : le hachage des mots de passe (bcrypt, cf. SecuriteConfig)
public UtilisateursService(PasswordEncoder hachage) {
// on ne conserve JAMAIS un mot de passe, seulement son hachage
utilisateurs = List.of(
new Utilisateur("marie", "Marie Pélissier", "USER", hachage.encode("marie")),
new Utilisateur("admin", "Administrateur", "ADMIN", hachage.encode("admin")));
}
public Optional<Utilisateur> chercher(String login) {
return utilisateurs.stream().filter(u -> u.login().equals(login)).findFirst();
}
}
|
- lignes 13-14 : un utilisateur, avec son rôle, sous forme de record imbriqué dans le service ;
- lignes 19-24 : on ne conserve jamais un mot de passe, seulement son hachage : une empreinte calculée par une fonction à sens unique. On peut vérifier qu'un mot de passe correspond à une empreinte, mais pas retrouver le mot de passe à partir de l'empreinte. Le service reçoit par injection un [PasswordEncoder], l'interface de [Spring Security] pour le hachage des mots de passe, et l'utilise pour fabriquer les empreintes des deux comptes de test (ligne 22) ;
- lignes 26-28 : la recherche d'un utilisateur par son login rend un [Optional<Utilisateur>] : une boîte qui contient un utilisateur, ou qui est vide. La liste est parcourue comme un flux ([stream()]) : [filter] ne garde que les éléments qui vérifient la condition, [findFirst] rend le premier (dans un [Optional]).
Le fichier [auth/AuthService.java] :
| package exemple25.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.springframework.http.ResponseCookie;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
import exemple25.auth.UtilisateursService.Utilisateur;
@Service
public class AuthService {
// le nom du cookie qui transporte le jeton
public static final String COOKIE = "jeton";
private final UtilisateursService utilisateurs;
private final PasswordEncoder hachage;
private final JwtEncoder encodeur;
private final JwtProprietes proprietes;
public AuthService(UtilisateursService utilisateurs, PasswordEncoder hachage, JwtEncoder encodeur,
JwtProprietes proprietes) {
this.utilisateurs = utilisateurs;
this.hachage = hachage;
this.encodeur = encodeur;
this.proprietes = proprietes;
}
// l'utilisateur, ou "rien" si le login est inconnu ou le mot de passe faux
public Optional<Utilisateur> valider(String login, String motDePasse) {
if (login == null || motDePasse == null) {
return Optional.empty();
}
return utilisateurs.chercher(login).filter(u -> hachage.matches(motDePasse, u.hachageMotDePasse()));
}
// le jeton JWT de l'utilisateur, signé avec la clé secrète
public String creerJeton(Utilisateur utilisateur) {
Instant maintenant = Instant.now();
// le contenu du jeton : les "claims" (affirmations sur l'utilisateur)
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(utilisateur.login())
.claim("nom", utilisateur.nom())
.claim("role", utilisateur.role())
.issuedAt(maintenant)
.expiresAt(maintenant.plus(duree()))
.build();
// l'entête : l'algorithme de signature (HMAC-SHA256)
JwsHeader entete = JwsHeader.with(MacAlgorithm.HS256).build();
return encodeur.encode(JwtEncoderParameters.from(entete, claims)).getTokenValue();
}
// le cookie qui transporte le jeton : le JavaScript de la page ne peut pas le lire
public ResponseCookie cookie(String jeton) {
return ResponseCookie.from(COOKIE, jeton).path("/").httpOnly(true).sameSite("Lax").maxAge(duree()).build();
}
private Duration duree() {
return Duration.ofMinutes(proprietes.dureeMinutes());
}
}
|
Commentons ce code :
- ligne 22 : le nom du cookie qui transporte le jeton ;
- lignes 29-35 : le service reçoit par son constructeur le service des utilisateurs, l'encodeur de mots de passe, l'encodeur de jetons [JwtEncoder] et les propriétés [jwt.*]. Ces trois derniers beans sont déclarés dans [SecuriteConfig] ;
- lignes 38-43 : la vérification du login et du mot de passe. [Optional.filter] vide la boîte si la condition est fausse : le résultat contient l'utilisateur seulement si le login existe et si [hachage.matches(motDePasse, empreinte)] reconnaît le mot de passe ;
- lignes 49-55 : le contenu du jeton, les claims (affirmations sur l'utilisateur). [subject] (le claim [sub]) est le nom conventionnel de l'identifiant de l'utilisateur. On y met ce dont les pages ont besoin (nom, rôle), pour ne pas relire la base de données à chaque requête. [issuedAt] et [expiresAt] donnent les claims [iat] (date de création) et [exp] (date d'expiration) ;
- ligne 57 : l'entête du jeton, qui indique l'algorithme de signature : HMAC-SHA256, noté [HS256] ;
- ligne 58 : l'encodeur fabrique le jeton signé ; [getTokenValue()] en rend la forme texte ;
- lignes 62-64 : le cookie qui transporte le jeton, fabriqué par la classe [ResponseCookie] de [Spring] : [httpOnly(true)] interdit au [JavaScript] de la page de le lire, ce qui protège le jeton si un script malveillant parvenait à s'exécuter dans la page ; [sameSite("Lax")] empêche le navigateur de le joindre aux requêtes POST émises depuis un autre site (cf. plus bas, la protection [CSRF]) ; [maxAge] donne au cookie la durée de vie du jeton.
Le fichier [config/SecuriteConfig.java] est le cœur de l'exemple. Sa première partie déclare les beans dont la sécurité a besoin :
| package exemple25.config;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseCookie;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.util.WebUtils;
import exemple25.auth.AuthService;
import exemple25.auth.JwtProprietes;
import jakarta.servlet.http.Cookie;
// la configuration de Spring Security
@Configuration
@EnableConfigurationProperties(JwtProprietes.class)
public class SecuriteConfig {
// la clé qui signe et vérifie les jetons (algorithme symétrique HMAC-SHA256)
@Bean
public SecretKey cleJwt(JwtProprietes proprietes) {
return new SecretKeySpec(proprietes.secret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
// la fabrication des jetons (bibliothèque Nimbus, apportée par Spring Security)
@Bean
public JwtEncoder jwtEncoder(SecretKey cle) {
return NimbusJwtEncoder.withSecretKey(cle).algorithm(MacAlgorithm.HS256).build();
}
// la vérification des jetons : signature et date d'expiration
@Bean
public JwtDecoder jwtDecoder(SecretKey cle) {
return NimbusJwtDecoder.withSecretKey(cle).macAlgorithm(MacAlgorithm.HS256).build();
}
// le hachage des mots de passe : bcrypt, avec un coût de 10
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(10);
}
|
- ligne 33 : une classe de configuration, dont les méthodes [@Bean] fabriquent des beans (cf. exemple 06) ;
- ligne 34 : l'enregistrement du record [JwtProprietes] comme bean, rempli à partir de [application.properties] ;
- lignes 38-41 : la clé de signature, fabriquée à partir du texte de la propriété [jwt.secret]. La même clé sert à signer et à vérifier les jetons : l'algorithme est symétrique. Une méthode [@Bean] peut recevoir d'autres beans en paramètres : ici les propriétés [jwt.*] ;
- lignes 44-47 : l'encodeur de jetons, qui signe avec la clé. [NimbusJwtEncoder] s'appuie sur la bibliothèque [Nimbus] ;
- lignes 50-53 : le décodeur, qui vérifie chaque jeton reçu : la signature (avec la même clé), puis la date d'expiration (avec une tolérance de 60 secondes, pour les horloges mal réglées). Le filtre d'authentification l'utilise pour chaque requête qui porte un jeton ;
- lignes 56-59 : le hachage des mots de passe par l'algorithme [bcrypt], avec un « coût » de 10 : [bcrypt] est volontairement lent (2¹⁰ tours de calcul), pour décourager qui voudrait essayer des millions de mots de passe. Deux hachages du même mot de passe sont différents (un « sel » aléatoire est ajouté) : c'est [matches] qui sait les vérifier.
La seconde partie décrit la chaîne des filtres de sécurité :
| // la chaîne des filtres de sécurité : qui peut accéder à quoi, et comment
// l'utilisateur est reconnu
@Bean
public SecurityFilterChain filtresSecurite(HttpSecurity http) throws Exception {
http
// 1. les règles d'accès, examinées dans l'ordre
.authorizeHttpRequests(acces -> acces
.requestMatchers("/", "/login", "/error", "/bootstrap/**").permitAll()
.anyRequest().authenticated())
// 2. l'authentification par jeton JWT ("resource server" OAuth2)
.oauth2ResourceServer(jwt -> jwt
.bearerTokenResolver(jetonDuCookie())
.authenticationEntryPoint(versLogin())
.jwt(config -> config.jwtAuthenticationConverter(convertisseur())))
// 3. page protégée demandée sans jeton : direction la page de connexion
.exceptionHandling(erreurs -> erreurs.authenticationEntryPoint(versLogin()))
// 4. la déconnexion (POST /logout) : effacer le cookie, puis aller à /login
.logout(sortie -> sortie.logoutUrl("/logout").deleteCookies(AuthService.COOKIE)
.logoutSuccessUrl("/login"))
// 5. le serveur est sans état : pas de session HTTP
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 6. pas de jeton anti-CSRF : le cookie SameSite=Lax protège déjà
// les requêtes POST venues d'autres sites
.csrf(csrf -> csrf.disable());
return http.build();
}
// le jeton est lu dans le cookie (et non dans l'entête Authorization)
private static BearerTokenResolver jetonDuCookie() {
return request -> {
Cookie cookie = WebUtils.getCookie(request, AuthService.COOKIE);
return cookie == null ? null : cookie.getValue();
};
}
// pas d'utilisateur authentifié (pas de jeton, jeton invalide ou expiré) :
// on efface le cookie éventuel et on redirige vers la page de connexion
private static AuthenticationEntryPoint versLogin() {
return (request, response, exception) -> {
if (WebUtils.getCookie(request, AuthService.COOKIE) != null) {
response.addHeader("Set-Cookie", ResponseCookie.from(AuthService.COOKIE, "").path("/").maxAge(0).build().toString());
}
response.sendRedirect("/login");
};
}
// du jeton à l'utilisateur authentifié : le claim [nom] est son nom, le claim
// [role] son rôle ; Spring Security note les rôles avec le préfixe ROLE_
private static JwtAuthenticationConverter convertisseur() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("role");
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter convertisseur = new JwtAuthenticationConverter();
convertisseur.setJwtGrantedAuthoritiesConverter(roles);
convertisseur.setPrincipalClaimName("nom");
return convertisseur;
}
}
|
Commentons ce code :
- lignes 4-5 : un bean de type [SecurityFilterChain] : la chaîne des filtres de sécurité. [Spring Boot] fournit un objet [HttpSecurity], qu'on règle, méthode après méthode, avant de construire la chaîne (ligne 26). Chaque méthode reçoit une lambda qui règle une partie de la configuration ;
- lignes 8-10 : les règles d'accès aux URL, examinées dans l'ordre : [/], [/login], [/error] (la page d'erreur de [Spring Boot]) et les fichiers de [Bootstrap] sont publics ([permitAll()]) ; toute autre URL ([anyRequest()]) exige un utilisateur authentifié. La page [/profil] est donc protégée, sans qu'on ait rien à écrire dans son contrôleur ;
- lignes 12-15 : l'authentification par jeton JWT. Par défaut, le serveur de ressources cherche le jeton dans l'entête [Authorization: Bearer ...] de la requête : c'est ainsi que procède un client [JavaScript] ou une application mobile. Un navigateur n'ajoute pas cet entête de lui-même : on lui indique d'aller chercher le jeton dans le cookie (ligne 13). La ligne 15 remplace la façon dont le jeton devient un utilisateur (cf. lignes 50-58) ;
- lignes 14 et 17 : le point d'entrée d'authentification ([authentication entry point]) est appelé quand une page protégée est demandée sans utilisateur authentifié. Par défaut, la réponse est une erreur 401, sans contenu : utile à un client [JavaScript], déroutante pour un utilisateur. On redirige vers la page de connexion. Il est déclaré deux fois : le filtre du serveur de ressources appelle directement son propre point d'entrée quand la requête porte un jeton qu'il refuse (ligne 14) ; la ligne 17 couvre le cas d'une page protégée demandée sans jeton ;
- lignes 19-20 : la déconnexion est prise en charge par [Spring Security] lui-même, par un filtre ([LogoutFilter]) qui intercepte [POST /logout] : il efface le cookie du jeton ([deleteCookies]) et redirige vers [/login]. Aucune action de contrôleur n'est nécessaire ;
- ligne 22 : [STATELESS] : [Spring Security] ne crée pas de session HTTP (cf. chapitre 6) et ne s'en sert pas pour retenir l'utilisateur. Celui-ci est reconnu, à chaque requête, par le seul jeton ;
- ligne 25 : la protection [CSRF] ([Cross-Site Request Forgery], falsification de requête inter-sites) est désactivée. Une attaque [CSRF] consiste, pour un site malveillant, à faire envoyer par le navigateur de la victime un formulaire POST vers notre site : le navigateur y joindrait le cookie, et la requête serait authentifiée. Par défaut, [Spring Security] exige dans chaque formulaire POST un jeton anti-CSRF, un champ caché que le site malveillant ne peut pas connaître. Ici, le cookie [SameSite=Lax] suffit : le navigateur ne le joint pas à une requête POST venue d'un autre site, qui arrive donc sans utilisateur authentifié. Il faut en revanche qu'aucune requête GET ne modifie de données (cf. exemple 21) : un cookie [Lax] accompagne les liens venus d'autres sites ;
- lignes 30-35 : le résolveur du jeton ([BearerTokenResolver]) : une interface à une seule méthode, écrite ici sous forme de lambda, qui rend le jeton de la requête, ou [null]. [WebUtils.getCookie] cherche un cookie par son nom ;
- lignes 39-46 : le point d'entrée : une redirection vers [/login] (ligne 44). Si la requête portait un cookie [jeton], c'est que le jeton est invalide (signature fausse) ou expiré : on efface le cookie, en le redéposant vide avec une durée de vie nulle (lignes 41-43). Sans cela, le navigateur renverrait le jeton refusé avec la requête [GET /login], que le filtre refuserait à son tour : une boucle de redirections ;
- lignes 50-58 : du jeton à l'utilisateur authentifié. Pour [Spring Security], les droits d'un utilisateur sont des autorités ([authorities]) : de simples chaînes de caractères. Par défaut, le serveur de ressources les lit dans le claim [scope] du jeton, avec le préfixe [SCOPE_]. On les lit dans le claim [role] (ligne 52), avec le préfixe [ROLE_] (ligne 53), celui que [Spring Security] attend pour un rôle : le rôle [USER] du jeton devient l'autorité [ROLE_USER]. La ligne 56 indique que le nom de l'utilisateur ([Authentication.getName()]) est le claim [nom], et non [sub].
Les filtres que [Spring Security] a placés dans la chaîne sont écrits dans la console au démarrage, si l'on demande le niveau de journalisation [DEBUG] pour la classe [DefaultSecurityFilterChain] :
| ../mvnw spring-boot:run -Dspring-boot.run.arguments=--logging.level.org.springframework.security.web.DefaultSecurityFilterChain=debug
|
| ... DEBUG ... o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, OAuth2ProtectedResourceMetadataFilter, BearerTokenAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter
|
On y reconnaît notamment :
- [HeaderWriterFilter], qui ajoute à chaque réponse des entêtes de sécurité ([X-Content-Type-Options: nosniff], [X-Frame-Options: DENY], [Cache-Control: no-cache, no-store...]) : ils ne sont pas reproduits dans les réponses de ce chapitre ;
- [LogoutFilter], qui traite [/logout] ;
- [BearerTokenAuthenticationFilter], le filtre du serveur de ressources : il lit le jeton (avec notre résolveur), le vérifie (avec le décodeur) et range l'utilisateur authentifié dans le contexte de sécurité ;
- [AnonymousAuthenticationFilter] : sans jeton, l'utilisateur est « anonyme » ;
- [ExceptionTranslationFilter], qui transforme les refus d'accès en réponses HTTP : point d'entrée pour un anonyme, erreur 403 pour un utilisateur authentifié (exemple 26) ;
- [AuthorizationFilter], le dernier, qui applique les règles [authorizeHttpRequests].
Le fichier [controllers/AuthController.java] :
| package exemple25.controllers;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import exemple25.auth.AuthService;
import exemple25.auth.UtilisateursService.Utilisateur;
import jakarta.servlet.http.HttpServletResponse;
@Controller
public class AuthController {
private final AuthService auth;
public AuthController(AuthService auth) {
this.auth = auth;
}
@GetMapping("/")
public String accueil() {
return "redirect:/profil";
}
@GetMapping("/login")
public String formulaire() {
return "auth/login";
}
@PostMapping("/login")
public String connexion(@RequestParam(required = false) String login,
@RequestParam(required = false) String motDePasse, HttpServletResponse response, Model model) {
Optional<Utilisateur> utilisateur = auth.valider(login, motDePasse);
if (utilisateur.isEmpty()) {
// un message volontairement vague : on ne dit pas si c'est le login ou le mot de passe
model.addAttribute("erreur", "Login ou mot de passe incorrect.");
model.addAttribute("login", login);
return "auth/login";
}
// le jeton, dans un cookie
response.addHeader(HttpHeaders.SET_COOKIE, auth.cookie(auth.creerJeton(utilisateur.get())).toString());
return "redirect:/profil";
}
}
|
Commentons ce code :
- lignes 35-44 : la connexion. Les paramètres sont facultatifs ([required = false]) : un paramètre absent vaut [null], et c'est [AuthService.valider] qui le refuse. En cas d'échec, le formulaire est réaffiché avec un message volontairement vague : dire « ce login n'existe pas » aiderait un attaquant à trouver des logins valides ;
- ligne 46 : en cas de succès, le cookie est ajouté à la réponse sous la forme d'un entête [Set-Cookie], avant la redirection vers le profil ;
- il n'y a pas d'action [/logout] : la déconnexion est traitée par le filtre [LogoutFilter], avant même que la requête n'atteigne [Spring MVC].
Le fichier [controllers/ProfilController.java] :
| package exemple25.controllers;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
// réservé aux utilisateurs authentifiés : cf. SecuriteConfig (anyRequest().authenticated())
@Controller
public class ProfilController {
// Authentication : l'utilisateur authentifié par le jeton ;
// @AuthenticationPrincipal Jwt : le jeton lui-même, déjà vérifié
@GetMapping("/profil")
public String profil(Authentication utilisateur, @AuthenticationPrincipal Jwt jeton, Model model) {
model.addAttribute("nom", utilisateur.getName());
model.addAttribute("admin", utilisateur.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")));
model.addAttribute("claims", jeton.getClaims());
return "profil/profil";
}
}
|
- ligne 10 : aucune annotation de sécurité sur le contrôleur : la règle [anyRequest().authenticated()] de [SecuriteConfig] protège déjà [/profil] ;
- ligne 17 : une action peut recevoir en paramètre l'utilisateur authentifié, de type [Authentication], rangé dans le contexte de sécurité par le filtre du serveur de ressources. L'annotation [@AuthenticationPrincipal] donne accès au principal de cet utilisateur : pour une authentification par JWT, c'est le jeton lui-même (type [Jwt]), déjà vérifié ;
- ligne 18 : le nom de l'utilisateur, le claim [nom] (cf. [setPrincipalClaimName]) ;
- lignes 19-20 : l'utilisateur est-il administrateur ? On cherche l'autorité [ROLE_ADMIN] parmi ses autorités ;
- ligne 21 : les claims du jeton, sous forme de dictionnaire ([Map<String, Object>]).
Le fichier [templates/profil/profil.html] :
| <!doctype html>
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
<h1 class="h3" th:text="|Bonjour ${nom}|">Bonjour</h1>
<p>Votre rôle : <b th:text="${admin} ? 'administrateur' : 'utilisateur'">utilisateur</b>.</p>
<p>Le contenu de votre jeton :</p>
<table class="table table-sm w-auto">
<!--/* une boucle sur un dictionnaire : chaque élément a une clé et une valeur */-->
<tr th:each="claim : ${claims}"><td><code th:text="${claim.key}">sub</code></td><td th:text="${claim.value}">marie</td></tr>
</table>
<!--/* la déconnexion : un formulaire POST réduit à son bouton */-->
<form method="post" action="/logout">
<button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
</form>
</main>
</body>
</html>
|
- ligne 10 : [th:each] parcourt aussi un dictionnaire : chaque élément a une clé ([claim.key]) et une valeur ([claim.value]) ;
- lignes 13-15 : la déconnexion, par un formulaire POST réduit à son bouton.
Le fichier [templates/auth/login.html] :
| <!doctype html>
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
<h1 class="h3">Connexion</h1>
<form method="post" action="/login">
<div class="mb-2">
<label class="form-label" for="login">Login</label>
<input class="form-control" id="login" name="login" th:value="${login}" required>
</div>
<div class="mb-3">
<label class="form-label" for="motDePasse">Mot de passe</label>
<input class="form-control" type="password" id="motDePasse" name="motDePasse" required>
</div>
<button class="btn btn-primary" type="submit">Se connecter</button>
</form>
<div class="alert alert-danger mt-3" th:if="${erreur != null}" th:text="${erreur}">erreur</div>
<p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
</main>
</body>
</html>
|
- ligne 9 : le login saisi est réaffiché après un échec ; le mot de passe, jamais ;
- ligne 17 : le message d'erreur, s'il y en a un.
Le gabarit [templates/layout.html] et la classe [config/WebConfig.java], qui sert les fichiers de [Bootstrap] sous l'URL [/bootstrap/...], sont ceux des exemples précédents (cf. exemple 15 et chapitre 1).
8.1.1. Exécution
Sans cookie, la page protégée redirige vers la page de connexion :
| curl -i http://localhost:5000/profil
|
| HTTP/1.1 302
Location: http://localhost:5000/login
Content-Length: 0
|
([Tomcat] n'écrit pas le texte [Found] qui suit habituellement le code 302 : il est facultatif.)

Un mot de passe erroné :

La connexion réussie, vue par [curl] :
| curl -i -c cookies.txt -d "login=marie&motDePasse=marie" http://localhost:5000/login
|
| HTTP/1.1 302
Set-Cookie: jeton=eyJraWQiOiJrdlJZbDMtVjlkanpHVXVZVHFWWUlXMXFUNmdmNlBnM2pNQXZJYkRIQWtFIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJtYXJpZSIsInJvbGUiOiJVU0VSIiwiZXhwIjoxNzkwMzU4MzU2LCJpYXQiOjE3OTAzNTQ3NTYsIm5vbSI6Ik1hcmllIFDDqWxpc3NpZXIifQ.DMTbpy7FI7WzBwL_FeKiZH_e_Qnrk7w_SX9lPFKoduA; Path=/; Max-Age=3600; Expires=Fri, 25 Sep 2026 17:45:56 GMT; HttpOnly; SameSite=Lax
Location: http://localhost:5000/profil
Content-Length: 0
|
Le jeton est formé de trois parties séparées par des points : un entête, le contenu, la signature. Les deux premières sont simplement encodées en [base64url]. L'entête décodé :
| {"kid":"kvRYl3-V9djzGUuYTqVYIW1qT6gf6Pg3jMAvIbDHAkE","alg":"HS256"}
|
On y trouve l'algorithme de signature ([alg]) et un identifiant de clé ([kid], [key id]) : quand un serveur utilise plusieurs clés, le [kid] lui indique laquelle a signé le jeton. [NimbusJwtEncoder] le calcule lui-même à partir de la clé (c'est l'empreinte SHA-256 de la clé, selon la norme [JWK Thumbprint]) : il ne révèle rien de la clé. Le contenu décodé :
| {
"sub": "marie",
"role": "USER",
"exp": 1790358356,
"iat": 1790354756,
"nom": "Marie Pélissier"
}
|
On retrouve les claims fabriqués par [creerJeton]. Les dates [exp] et [iat] sont écrites en secondes depuis le 1er janvier 1970. Le jeton n'est pas chiffré : on n'y met rien de secret. Dans le navigateur, à l'URL http://localhost:5000/profil :

Dans la page, les dates [exp] et [iat] sont affichées au format ISO 8601 : le décodeur les a converties en objets [Instant] (un instant, en temps universel, d'où le [Z] final). On constate que le jeton est valable une heure.
Tentons maintenant de modifier le jeton pour nous attribuer le rôle ADMIN : on décode le contenu, on remplace [USER] par [ADMIN], on le réencode... mais on ne peut pas recalculer la signature sans la clé secrète :
| curl -i -b "jeton=eyJraWQiOi...(contenu modifié)...(ancienne signature)" http://localhost:5000/profil
|
| HTTP/1.1 302
Set-Cookie: jeton=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Location: http://localhost:5000/login
Content-Length: 0
|
La signature ne correspond plus au contenu : le décodeur refuse le jeton, le filtre appelle le point d'entrée, qui efface le cookie et renvoie vers la page de connexion. La déconnexion :
| curl -i -b cookies.txt -X POST http://localhost:5000/logout
|
| HTTP/1.1 302
Set-Cookie: jeton=; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/
Location: http://localhost:5000/login
Content-Length: 0
|
Le filtre [LogoutFilter] efface le cookie en le redéposant, vide, avec une date d'expiration passée. Remarquons que, la protection [CSRF] étant désactivée, [Spring Security] accepte aussi la déconnexion par un GET ([curl -i http://localhost:5000/logout] donne la même réponse) : c'est sans danger ici (au pire, un lien piégé déconnecte l'utilisateur), mais nos pages, elles, déconnectent toujours par un POST.
8.2. Exemple 26 : les rôles
L'exemple 26 reprend l'exemple 25 et y ajoute une page réservée au rôle ADMIN. Le rôle est déjà une autorité de l'utilisateur authentifié ([ROLE_ADMIN]) : il reste à l'exiger. Le fichier [controllers/AdminController.java] :
| package exemple26.controllers;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
// tout le contrôleur est réservé au rôle ADMIN : il faut être authentifié ET
// avoir le rôle ADMIN (le claim "role" du jeton, cf. SecuriteConfig)
@Controller
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
@GetMapping("/admin")
public String admin(Authentication utilisateur, Model model) {
model.addAttribute("nom", utilisateur.getName());
return "admin/admin";
}
}
|
- ligne 12 : l'annotation [@PreAuthorize] pose une condition, vérifiée avant l'appel de chaque action du contrôleur. La condition est une expression du langage [SpEL] ([Spring Expression Language]) : [hasRole('ADMIN')] est vraie si l'utilisateur a l'autorité [ROLE_ADMIN] (le préfixe [ROLE_] est ajouté par [hasRole]). Un utilisateur anonyme est d'abord envoyé à la page de connexion (point d'entrée) ; un utilisateur authentifié sans ce rôle reçoit une réponse 403, « accès interdit ». [hasAnyRole('ADMIN', 'DOCTOR')] accepterait l'un ou l'autre de ces rôles.
Pour que [@PreAuthorize] soit prise en compte, la classe [SecuriteConfig] porte une annotation de plus :
| // la configuration de Spring Security.
// @EnableMethodSecurity : active les annotations @PreAuthorize des contrôleurs
@Configuration
@EnableMethodSecurity
@EnableConfigurationProperties(JwtProprietes.class)
public class SecuriteConfig {
|
- ligne 4 : [@EnableMethodSecurity] active la sécurité des méthodes. [Spring] enveloppe alors chaque bean dont une méthode porte [@PreAuthorize] dans un proxy : un objet intermédiaire, qui reçoit les appels à la place du bean, vérifie la condition, puis appelle la vraie méthode ou lève une exception [AccessDeniedException]. Nous retrouverons ce mécanisme de proxy avec les transactions, au chapitre suivant.
La chaîne des filtres a changé en deux endroits :
| .oauth2ResourceServer(jwt -> jwt
.bearerTokenResolver(jetonDuCookie())
.authenticationEntryPoint(versLogin())
.accessDeniedHandler(accesRefuse())
.jwt(config -> config.jwtAuthenticationConverter(convertisseur())))
// 3. page protégée demandée sans jeton : direction la page de connexion ;
// rôle insuffisant : une réponse 403, transformée en page par ErreurController
.exceptionHandling(erreurs -> erreurs.authenticationEntryPoint(versLogin())
.accessDeniedHandler(accesRefuse()))
|
- lignes 4 et 9 : le gestionnaire des refus d'accès ([access denied handler]). L'exception levée par [@PreAuthorize] remonte, à travers [Spring MVC], jusqu'au filtre [ExceptionTranslationFilter], qui la confie à ce gestionnaire. Celui du serveur de ressources renverrait une réponse 403 sans contenu, avec un entête [WWW-Authenticate] destiné à un client [JavaScript]. Comme pour le point d'entrée, on le remplace à deux endroits.
Le gestionnaire lui-même, en fin de fichier :
| // rôle insuffisant : une réponse d'erreur 403, que le serveur transmet à /error
private static AccessDeniedHandler accesRefuse() {
return (request, response, exception) -> response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
|
- lignes 2-4 : notre gestionnaire se contente de [sendError(403)]. Le serveur [Tomcat] transmet alors la requête à l'URL [/error] (cf. exemple 24), c'est-à-dire au contrôleur d'erreur. C'est pourquoi [/error] figure parmi les URL publiques : la requête transmise traverse, elle aussi, la chaîne des filtres.
Le fichier [controllers/ErreurController.java] :
| package exemple26.controllers;
import org.springframework.boot.webmvc.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
// la page des réponses d'erreur (403, 404...) : cf. exemple 24
@Controller
public class ErreurController implements ErrorController {
@RequestMapping("/error")
public String erreur(HttpServletRequest request, HttpServletResponse response, Model model) {
Object code = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
int statut = code instanceof Integer c ? c : 500;
response.setStatus(statut);
model.addAttribute("code", statut);
return "erreur";
}
}
|
- lignes 14-16 : le contrôleur de la page d'erreur, comme à l'exemple 24 : il implémente l'interface [ErrorController] de [Spring Boot], et remplace ainsi la page d'erreur par défaut ;
- lignes 18-21 : le code d'état de la réponse d'erreur (403, 404...) est un attribut de la requête transmise ; on le recopie dans la réponse et dans le modèle de la vue.
La vue [templates/erreur.html] :
| <!doctype html>
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
<div class="alert alert-danger">
<h1 class="h4" th:text="|Erreur ${code}|">Erreur 403</h1>
<p class="mb-0" th:text="${code == 403} ? 'Accès refusé : cette page est réservée aux administrateurs.' : 'Une erreur est survenue.'">message</p>
</div>
<p><a href="/profil">Retour au profil</a></p>
</main>
</body>
</html>
|
- ligne 7 : un message propre à l'erreur 403, un message générique pour les autres.
Enfin, la page de profil a un lien de plus, vers [/admin]. L'utilisatrice [marie] (rôle USER) tente d'ouvrir la page d'administration depuis son profil :


La même tentative par [admin] :

Avec [curl], on voit le code 403 :
| curl -i -c cookies.txt -d "login=marie&motDePasse=marie" http://localhost:5000/login
curl -i -b cookies.txt http://localhost:5000/admin
|
| HTTP/1.1 403
Content-Type: text/html;charset=UTF-8
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Authentification</title>
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
<div class="container py-3" style="max-width: 34rem">
<main>
<div class="alert alert-danger">
<h1 class="h4">Erreur 403</h1>
<p class="mb-0">Accès refusé : cette page est réservée aux administrateurs.</p>
</div>
<p><a href="/profil">Retour au profil</a></p>
</main>
</div>
</body>
</html>
|
L'étude de cas a trois rôles (ADMIN, DOCTOR, USER) et utilise exactement ces mécanismes : la règle [anyRequest().authenticated()] protège toutes les pages sauf la connexion et la création de compte, [@PreAuthorize("hasRole('ADMIN')")] réserve la gestion des médecins et des clients à l'administrateur, [@PreAuthorize("hasRole('USER')")] réserve l'espace des patients aux patients. La page 403 y est traduite.
8.3. Exemple 27 : captcha et limitation des tentatives
Une page de connexion attire les robots : des programmes qui essaient des milliers de couples login / mot de passe. Deux protections complémentaires :
- un captcha : une image de caractères déformés, qu'un humain lit sans peine mais qu'un robot simple ne sait pas déchiffrer ;
- une limitation des tentatives : après quelques échecs, l'adresse IP est bloquée un moment.
L'exemple 27 ajoute ces deux protections à l'exemple 25. Le fichier [auth/CaptchaService.java] :
| package exemple27.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
// singleton : les captchas en attente sont gardés en mémoire par le serveur
@Service
public class CaptchaService {
// un captcha : son identifiant, et son image (SVG)
public record Captcha(String id, String svg) {
}
// la réponse attendue, et sa date d'expiration
private record Attendu(String texte, Instant expire) {
}
private static final Duration DUREE_VIE = Duration.ofMinutes(10);
// identifiant -> réponse attendue.
// ConcurrentHashMap : un dictionnaire utilisable par plusieurs requêtes simultanées
private final Map<String, Attendu> enAttente = new ConcurrentHashMap<>();
public Captcha generer() {
String texte = DessinCaptcha.texteAleatoire(5);
String id = UUID.randomUUID().toString(); // un identifiant aléatoire, impossible à deviner
enAttente.put(id, new Attendu(texte, Instant.now().plus(DUREE_VIE)));
return new Captcha(id, DessinCaptcha.svg(texte));
}
// le captcha est CONSOMMÉ dans tous les cas : une image ne sert qu'une fois
public boolean verifier(String id, String saisie) {
Attendu attendu = id == null ? null : enAttente.remove(id);
return attendu != null && saisie != null
&& attendu.expire().isAfter(Instant.now())
&& attendu.texte().equalsIgnoreCase(saisie.trim());
}
}
|
Commentons ce code :
- lignes 11-13 : un service est un bean singleton (cf. exemple 06) : une seule instance, partagée par toutes les requêtes. Les captchas en attente sont donc gardés en mémoire par le serveur ;
- lignes 16-21 : deux records : le captcha envoyé à la page (identifiant et image), et la réponse attendue, gardée par le serveur ;
- ligne 27 : les réponses attendues sont rangées sous un identifiant aléatoire. La page ne contient que l'image et l'identifiant : la réponse ne quitte jamais le serveur. Le service est utilisé par des requêtes simultanées, exécutées par des threads différents : [ConcurrentHashMap] est un dictionnaire prévu pour cela ;
- lignes 29-34 : un texte de cinq caractères tiré au hasard, mémorisé pour dix minutes, et dessiné dans une image. [UUID.randomUUID()] fabrique un identifiant aléatoire de 128 bits, impossible à deviner ;
- lignes 37-42 : la vérification consomme le captcha ([remove] le retire du dictionnaire, et rend la valeur retirée) : une image ne sert qu'une fois, un robot ne peut pas rejouer une réponse trouvée une fois. Un captcha expiré est refusé ; les majuscules ne comptent pas ([equalsIgnoreCase]).
Le dessin est confié à la classe [auth/DessinCaptcha.java], reprise de l'étude de cas. Aucune bibliothèque n'est nécessaire : l'image est au format [SVG] ([Scalable Vector Graphics]), un format d'image vectoriel qui est du texte XML, que le navigateur sait afficher. Le début du fichier :
| // le dessin d'un captcha : une image SVG (du texte XML) fabriquée à la main
final class DessinCaptcha {
private static final int LARGEUR = 150;
private static final int HAUTEUR = 50;
// SecureRandom : un générateur de nombres aléatoires "cryptographique",
// imprévisible (contrairement à Random, dont la suite pourrait être devinée)
private static final SecureRandom HASARD = new SecureRandom();
// La "police" : chaque caractère est une liste de traits, chaque trait une
// suite de points (x, y) dans une grille de 4 x 6 (y vers le bas).
// Caractères trop faciles à confondre entre eux : exclus (0 O, 1 I L).
private static final Map<Character, double[][]> POLICE = new LinkedHashMap<>();
static {
|
- ligne 2 : une classe [final] (on ne peut pas en hériter), sans le mot-clé [public] : elle n'est visible que des classes de son package, [exemple27.auth] ;
- ligne 9 : le hasard est tiré par [SecureRandom], un générateur cryptographique : la suite de ses tirages est imprévisible, même pour qui connaît les tirages précédents. Ce n'est pas le cas de [Random], dont la suite peut être reconstituée à partir de quelques tirages : un robot pourrait alors prédire les captchas suivants ;
- lignes 11-14 : chaque caractère est décrit par une liste de traits dans une petite grille. Les caractères faciles à confondre (0 et O, 1, I et L) sont exclus. La suite du fichier (non reproduite) remplit ce dictionnaire, dans un bloc [static { ... }] exécuté une fois, au chargement de la classe.
Le dessin de l'image :
| static String svg(String texte) {
StringBuilder svg = new StringBuilder();
svg.append("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 %d %d\">"
.formatted(LARGEUR, HAUTEUR, LARGEUR, HAUTEUR));
svg.append("<rect width=\"100%\" height=\"100%\" fill=\"#f5f7fa\"/>");
// les caractères : chacun agrandi, penché, décalé, et chaque point
// légèrement déplacé au hasard
double pas = (LARGEUR - 20) / (double) texte.length();
for (int i = 0; i < texte.length(); i++) {
double echelle = hasard(4.6, 5.6); // taille d'une unité de la grille, en pixels
double angle = hasard(-0.35, 0.35); // rotation, en radians (environ ±20°)
double cx = 10 + pas * (i + 0.5) + hasard(-3, 3); // centre du caractère
double cy = HAUTEUR / 2.0 + hasard(-4, 4);
StringBuilder d = new StringBuilder();
for (double[] trait : POLICE.get(texte.charAt(i))) {
for (int k = 0; k < trait.length; k += 2) {
// point de la grille, centré sur (2, 3), déformé, tourné, placé
double x = (trait[k] - 2 + hasard(-0.2, 0.2)) * echelle;
double y = (trait[k + 1] - 3 + hasard(-0.2, 0.2)) * echelle;
double px = cx + x * Math.cos(angle) - y * Math.sin(angle);
double py = cy + x * Math.sin(angle) + y * Math.cos(angle);
d.append(k == 0 ? 'M' : 'L').append(nombre(px)).append(' ').append(nombre(py)).append(' ');
}
}
svg.append("<path d=\"%s\" fill=\"none\" stroke=\"%s\" stroke-width=\"%s\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>"
.formatted(d.toString().trim(), couleur(), nombre(hasard(2.2, 3))));
}
// 3 traits de brouillage : des courbes qui traversent l'image
for (int n = 0; n < 3; n++) {
svg.append("<path d=\"M%s %s C%s %s %s %s %s %s\" fill=\"none\" stroke=\"%s\" stroke-width=\"1.3\"/>"
.formatted(nombre(hasard(0, 20)), nombre(hasard(5, 45)),
nombre(hasard(30, 60)), nombre(hasard(0, 50)), nombre(hasard(90, 120)), nombre(hasard(0, 50)),
nombre(hasard(130, 150)), nombre(hasard(5, 45)), couleur()));
}
svg.append("</svg>");
return svg.toString();
}
// un nombre avec un point décimal, quelle que soit la langue du serveur
// (en français, "%.1f" écrirait "12,5" - illisible pour SVG)
private static String nombre(double valeur) {
return String.format(Locale.ROOT, "%.1f", valeur);
}
|
- lignes 3-5 : l'image SVG est un élément [<svg>] de 150 x 50 pixels, sur un fond clair (un rectangle [<rect>]). ["...".formatted(...)] remplace les [%d] et [%s] du texte par les valeurs données, comme [String.format] ;
- lignes 9-28 : chaque caractère devient un chemin [<path>] : une suite de points reliés par des segments ([M] : aller au point, [L] : tracer un segment jusqu'au point). Chaque caractère est agrandi, penché et décalé au hasard, et chacun de ses points légèrement déplacé : deux images du même texte ne sont jamais identiques ;
- lignes 31-36 : trois courbes de brouillage ([C] : une courbe de Bézier) traversent l'image, pour gêner un programme de reconnaissance de caractères ;
- lignes 43-45 : les nombres sont écrits avec un point décimal quelle que soit la langue du serveur ([Locale.ROOT]) : en français, [%.1f] écrirait une virgule, que SVG ne comprend pas.
Le fichier [auth/LimiteurService.java] :
| package exemple27.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
// singleton : compte les échecs de connexion de chaque adresse IP
@Service
public class LimiteurService {
private static final int MAX_ECHECS = 3;
private static final Duration DUREE_BLOCAGE = Duration.ofMinutes(1);
private static class Compteur {
int echecs;
Instant bloqueJusqua = Instant.MIN;
}
private final Map<String, Compteur> compteurs = new HashMap<>();
// synchronized : plusieurs requêtes simultanées, un seul accès à la fois
// le nombre de secondes de blocage restantes (0 : pas bloquée)
public synchronized long secondesBlocage(String ip) {
Compteur c = compteurs.get(ip);
if (c == null) {
return 0;
}
Duration reste = Duration.between(Instant.now(), c.bloqueJusqua);
return reste.isNegative() || reste.isZero() ? 0 : (reste.toMillis() + 999) / 1000;
}
public synchronized void echec(String ip) {
Compteur c = compteurs.computeIfAbsent(ip, cle -> new Compteur());
c.echecs++;
if (c.echecs >= MAX_ECHECS) {
c.bloqueJusqua = Instant.now().plus(DUREE_BLOCAGE);
c.echecs = 0; // après le blocage, on repart de zéro
}
}
public synchronized void reussite(String ip) {
compteurs.remove(ip);
}
}
|
- lignes 14-15 : trois échecs bloquent l'adresse IP une minute ;
- lignes 17-20 : le compteur d'une adresse : nombre d'échecs, et date de fin du blocage ([Instant.MIN], un instant très ancien, au départ) ;
- ligne 22 : le dictionnaire des compteurs est partagé par toutes les requêtes. C'est un simple [HashMap], qui ne supporte pas les accès simultanés : chaque méthode est donc [synchronized]. Le mot-clé garantit qu'un seul thread à la fois exécute l'une des méthodes [synchronized] de l'objet : deux échecs simultanés ne peuvent pas se perdre (cf. [AtomicInteger], chapitre 6) ;
- lignes 27-34 : le temps de blocage restant, arrondi à la seconde supérieure ;
- lignes 36-43 : un échec est compté ; au troisième, l'adresse est bloquée. [computeIfAbsent] rend le compteur de l'adresse, après l'avoir créé s'il n'existait pas ;
- lignes 45-47 : une réussite efface le compteur.
Le fichier [controllers/AuthController.java] :
| package exemple27.controllers;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import exemple27.auth.AuthService;
import exemple27.auth.CaptchaService;
import exemple27.auth.LimiteurService;
import exemple27.auth.UtilisateursService.Utilisateur;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Controller
public class AuthController {
private final AuthService auth;
private final CaptchaService captchas;
private final LimiteurService limiteur;
public AuthController(AuthService auth, CaptchaService captchas, LimiteurService limiteur) {
this.auth = auth;
this.captchas = captchas;
this.limiteur = limiteur;
}
@GetMapping("/")
public String accueil() {
return "redirect:/profil";
}
@GetMapping("/login")
public String formulaire(Model model) {
return login(model, null, null);
}
@PostMapping("/login")
public String connexion(@RequestParam(required = false) String login,
@RequestParam(required = false) String motDePasse, @RequestParam(required = false) String captchaId,
@RequestParam(required = false) String captcha, HttpServletRequest request,
HttpServletResponse response, Model model) {
// l'adresse IP du client
String ip = request.getRemoteAddr();
// 1. une adresse bloquée n'est même pas écoutée
long secondes = limiteur.secondesBlocage(ip);
if (secondes > 0) {
return login(model, login, "Trop de tentatives : réessayez dans " + secondes + " s.");
}
// 2. le captcha d'abord (peu coûteux), le mot de passe (bcrypt, coûteux) ensuite
if (!captchas.verifier(captchaId, captcha)) {
limiteur.echec(ip);
return login(model, login, "Le code de l'image est incorrect.");
}
Optional<Utilisateur> utilisateur = auth.valider(login, motDePasse);
if (utilisateur.isEmpty()) {
limiteur.echec(ip);
return login(model, login, "Login ou mot de passe incorrect.");
}
limiteur.reussite(ip);
response.addHeader(HttpHeaders.SET_COOKIE, auth.cookie(auth.creerJeton(utilisateur.get())).toString());
return "redirect:/profil";
}
// chaque affichage du formulaire fabrique un NOUVEAU captcha
private String login(Model model, String login, String erreur) {
model.addAttribute("login", login);
model.addAttribute("erreur", erreur);
model.addAttribute("captcha", captchas.generer());
return "auth/login";
}
}
|
Commentons ce code :
- ligne 48 : l'adresse IP du client, donnée par la connexion réseau ;
- lignes 51-54 : une adresse bloquée n'est même pas écoutée ;
- lignes 56-59 : le captcha est vérifié d'abord, puis le mot de passe (un calcul [bcrypt] coûteux) seulement si le captcha est bon. Un mauvais captcha compte comme un échec ;
- lignes 71-76 : chaque affichage du formulaire fabrique un nouveau captcha, placé dans le modèle de la vue. C'est aussi le cas du premier affichage (lignes 37-40).
Le fichier [templates/auth/login.html] :
| <!doctype html>
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
<h1 class="h3">Connexion</h1>
<form method="post" action="/login">
<div class="mb-2">
<label class="form-label" for="login">Login</label>
<input class="form-control" id="login" name="login" th:value="${login}" required>
</div>
<div class="mb-2">
<label class="form-label" for="motDePasse">Mot de passe</label>
<input class="form-control" type="password" id="motDePasse" name="motDePasse" required>
</div>
<div class="mb-3">
<label class="form-label" for="captcha">Recopiez le code de l'image</label>
<!--/* l'image SVG, fabriquée par le serveur : insérée sans échappement */-->
<div class="mb-1" th:utext="${captcha.svg}">image</div>
<!--/* l'identifiant du captcha voyage dans un champ caché ; la réponse, elle, reste sur le serveur */-->
<input type="hidden" name="captchaId" th:value="${captcha.id}">
<input class="form-control" id="captcha" name="captcha" autocomplete="off" required>
</div>
<button class="btn btn-primary" type="submit">Se connecter</button>
</form>
<div class="alert alert-danger mt-3" th:if="${erreur != null}" th:text="${erreur}">erreur</div>
<p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
</main>
</body>
</html>
|
- ligne 18 : l'image SVG est du texte (du XML) : elle est insérée telle quelle dans la page. [th:utext] ([unescaped text]) insère le texte sans échappement : les caractères [<] et [>] restent des balises (avec [th:text], ils deviendraient [<] et [>], et la page afficherait le code de l'image). [th:utext] est ici légitime : c'est du HTML fabriqué par le serveur, pas une saisie de l'utilisateur ;
- ligne 20 : l'identifiant du captcha voyage dans un champ caché ; la réponse, elle, reste sur le serveur ;
- ligne 21 : [autocomplete="off"] : le navigateur ne propose pas les saisies précédentes, sans intérêt pour un captcha.
Le fichier [config/SecuriteConfig.java] est exactement celui de l'exemple 25 (et non celui de l'exemple 26 : il n'y a ici ni [@EnableMethodSecurity], ni gestionnaire des refus d'accès, puisqu'aucune page n'est réservée à un rôle). En particulier, la règle d'accès [/login] est inchangée : la page de connexion reste publique, et le captcha est vérifié par le contrôleur, pas par [Spring Security].
La page de connexion, à l'URL http://localhost:5000/login :

Un captcha mal recopié :

Un nouveau captcha a été dessiné. Renvoyer un captcha déjà utilisé, même correctement recopié, donne le même message : la réponse a été retirée du dictionnaire lors de sa première vérification. Après trois échecs, même le bon mot de passe, avec le bon captcha, est refusé pendant une minute :

L'étude de cas applique ces deux protections à sa page de connexion et à la création de compte, avec des seuils réglables dans le fichier [application.properties] (propriétés [rdvmedecins.protection.*]).