Autre lien vers un ancien cours plus basique
Lien vers une synthèse rapide du cours
Lien vers un cours complet sur JWT & Spring Boot Security
Imaginez que vous ouvrez un magasin. Sans sécurité :
Le web, c’est pareil. Une application web non sécurisée est exposée à :
/admin
' OR '1'='1
Spring Security est le framework de sécurité de référence pour les applications Java/Spring. Il fournit :
**Analogie ** : Spring Security est comme le système de sécurité d’un immeuble de bureaux : La carte d’accès = l’authentification (prouver qui vous êtes) Les portes avec lecteur = l’autorisation (accéder seulement à vos étages) La caméra de surveillance = les logs de sécurité Le badge temporaire visiteur = les sessions
**Analogie ** : Spring Security est comme le système de sécurité d’un immeuble de bureaux :
Spring Security 6 (avec Spring Boot 3.x) apporte des changements majeurs par rapport aux versions antérieures :
extends WebSecurityConfigurerAdapter
http.authorizeRequests()
http.authorizeHttpRequests()
antMatchers("/url")
requestMatchers("/url")
WebSecurityConfigurerAdapter
SecurityFilterChain
Important : Ce cours utilise exclusivement la syntaxe Spring Security 6 (Spring Boot 3.x). Si vous voyez extends WebSecurityConfigurerAdapter quelque part, c’est une ancienne version — ne la copiez pas !
Spring Security repose sur une chaîne de filtres (Filter Chain). Chaque requête HTTP passe par cette chaîne avant d’atteindre votre application.
Requête HTTP │ ▼ ┌─────────────────────────────────────────────┐ │ FILTER CHAIN Spring Security │ │ │ │ 1. SecurityContextPersistenceFilter │ │ └─ Charge le contexte de sécurité │ │ │ │ 2. UsernamePasswordAuthenticationFilter │ │ └─ Traite les formulaires de login │ │ │ │ 3. BasicAuthenticationFilter │ │ └─ Traite l'auth Basic (API) │ │ │ │ 4. BearerTokenAuthenticationFilter │ │ └─ Traite les tokens JWT │ │ │ │ 5. ExceptionTranslationFilter │ │ └─ Gère les erreurs 401/403 │ │ │ │ 6. AuthorizationFilter │ │ └─ Vérifie les autorisations │ └─────────────────────────────────────────────┘ │ ▼ Votre Controller Spring MVC
Ce que ça signifie : Vous n’avez généralement pas à toucher à ces filtres directement. Spring Security les configure pour vous. Vous déclarez simplement vos règles (qui peut accéder à quoi), et Spring Security s’occupe du reste.
Ces deux termes sont souvent confondus, mais ils sont fondamentalement différents :
Authentification = Vérifier l’identité
"Je suis Alicia" + mot de passe → "Bonjour Alicia, vous êtes bien Alicia"
Autorisation = Vérifier les droits
Alicia veut accéder à /admin : "Alicia est identifiée, mais n'est pas admin" Alicia veut accéder à /profil : "Alicia est identifiée et a le droit d'accéder"
Analogie ** : Dans un aéroport, votre passeport = **authentification (prouve qui vous êtes). Votre billet de classe affaires = autorisation (prouve que vous avez droit au salon VIP).
Spring Security stocke les informations de l’utilisateur connecté dans un objet appelé SecurityContext, accessible partout dans l’application.
SecurityContext
// Récupérer l'utilisateur connecté depuis n'importe où SecurityContext context = SecurityContextHolder.getContext(); Authentication auth = context.getAuthentication(); String username = auth.getName(); // "alice@test.com" Object principal = auth.getPrincipal(); // L'objet UserDetails Collection<?> roles = auth.getAuthorities(); // [ROLE_USER, ROLE_ADMIN] boolean estConnecte = auth.isAuthenticated(); // true/false
Analogie 🎫 : Le SecurityContext est comme votre badge de visiteur dans une entreprise. Une fois que vous l’avez passé à l’accueil (authentification), vous le portez pendant toute votre visite et chaque porte peut le lire pour décider si elle s’ouvre.
Spring Security utilise deux interfaces clés pour l’authentification :
UserDetails : Représente un utilisateur avec ses informations de sécurité
UserDetails
public interface UserDetails extends Serializable { String getUsername(); // Identifiant (email ou login) String getPassword(); // Mot de passe (HASHÉ) Collection<? extends GrantedAuthority> getAuthorities(); // Rôles/permissions boolean isAccountNonExpired(); // Compte non expiré ? boolean isAccountNonLocked(); // Compte non verrouillé ? boolean isCredentialsNonExpired(); // Mot de passe non expiré ? boolean isEnabled(); // Compte actif ? }
UserDetailsService : Charge un utilisateur depuis une source de données
UserDetailsService
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
Rôle de UserDetailsService : C’est le “pont” entre votre base de données et Spring Security. Quand quelqu’un tente de se connecter, Spring Security appelle loadUserByUsername() pour récupérer les informations de l’utilisateur, puis compare le mot de passe fourni avec celui stocké.
loadUserByUsername()
Les rôles sont des autorisations nommées préfixées de ROLE_ :
ROLE_
ROLE_USER
ROLE_ADMIN
ROLE_MODERATEUR
Les authorities sont des permissions plus granulaires :
livre:lire
livre:ecrire
utilisateur:supprimer
// Rôle = authority préfixée ROLE_ // hasRole("ADMIN") est équivalent à hasAuthority("ROLE_ADMIN") // En pratique : // - Utilisez les RÔLES pour des niveaux d'accès généraux // - Utilisez les AUTHORITIES pour des permissions très spécifiques
On ne stocke JAMAIS un mot de passe en clair. C’est une règle absolue.
Spring Security utilise des encodeurs de mots de passe qui transforment un mot de passe en une empreinte irréversible :
"motdepasse123" → BCrypt → "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
BCrypt est l’encodeur recommandé :
// Jamais ça : user.setPassword("motdepasse123"); // MOT DE PASSE EN CLAIR // Toujours ça : PasswordEncoder encoder = new BCryptPasswordEncoder(); user.setPassword(encoder.encode("motdepasse123")); // HASHÉ // Résultat : "$2a$10$..."
<!-- pom.xml --> <dependencies> <!-- Spring Boot Web - Spring MVC --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- ★ Spring Security - Le cœur de ce cours --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- Thymeleaf - Moteur de templates --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- ★ Thymeleaf + Spring Security - Intégration dans les templates --> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6</artifactId> </dependency> <!-- Spring Data JPA - Accès base de données --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Validation des formulaires --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Base de données H2 en mémoire (développement) --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- JWT - Pour l'authentification stateless (chapitre 9) --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.3</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.3</version> <scope>runtime</scope> </dependency> <!-- Lombok - Réduction du code boilerplate --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Tests Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies>
src/ └── main/ ├── java/com/formation/security/ │ ├── SecurityApplication.java │ ├── config/ │ │ ├── SecurityConfig.java ← La configuration centrale │ │ └── DataInitializer.java ← Données de démo │ ├── controller/ │ │ ├── AccueilController.java │ │ ├── AdminController.java │ │ ├── UserController.java │ │ └── AuthController.java │ ├── domain/ │ │ ├── Utilisateur.java ← Entité JPA │ │ └── Role.java ← Enum des rôles │ ├── dto/ │ │ ├── InscriptionDTO.java │ │ └── LoginDTO.java │ ├── repository/ │ │ └── UtilisateurRepository.java │ └── service/ │ ├── UtilisateurService.java │ └── CustomUserDetailsService.java ← Bridge vers Spring Security └── resources/ ├── templates/ │ ├── fragments/ │ │ └── layout.html │ ├── admin/ │ │ └── dashboard.html │ ├── user/ │ │ └── profil.html │ ├── index.html │ ├── login.html │ └── inscription.html ├── static/css/ │ └── style.css └── application.properties
# Base de données H2 spring.datasource.url=jdbc:h2:mem:securitydb spring.datasource.driver-class-name=org.h2.Driver spring.jpa.hibernate.ddl-auto=create-drop spring.h2.console.enabled=true spring.h2.console.path=/h2-console # Thymeleaf spring.thymeleaf.cache=false # Logging Spring Security (utile pour le debug) logging.level.org.springframework.security=DEBUG # Nom de l'application spring.application.name=Formation Spring Security
Dès que vous ajoutez la dépendance spring-boot-starter-security, Spring Boot applique une configuration par défaut automatique :
spring-boot-starter-security
/login
user
Using generated security password: 8e557245-73e2-4286-969a-ff57fe326336
C’est le comportement de Spring Security “out of the box”. C’est bien pour un test rapide, mais totalement inadapté à la production. Ce chapitre vous apprend à le remplacer par votre propre configuration.
En Spring Security 6, toute la configuration se fait via un bean SecurityFilterChain :
// config/SecurityConfig.java @Configuration @EnableWebSecurity // Active Spring Security sur cette classe public class SecurityConfig { /** * LE bean central de Spring Security. * Il définit TOUTES les règles de sécurité de l'application. * * @param http L'objet HttpSecurity fourni par Spring Security * @return La chaîne de filtres configurée */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // ── 1. RÈGLES D'AUTORISATION ──────────────────────────────────── .authorizeHttpRequests(auth -> auth // Ces URLs sont accessibles par TOUT LE MONDE (même non connecté) .requestMatchers("/", "/accueil").permitAll() .requestMatchers("/css/**", "/js/**", "/images/**").permitAll() .requestMatchers("/inscription").permitAll() // Ces URLs nécessitent le rôle ADMIN .requestMatchers("/admin/**").hasRole("ADMIN") // Ces URLs nécessitent d'être connecté (peu importe le rôle) .requestMatchers("/user/**").authenticated() // Toutes les autres URLs nécessitent d'être connecté .anyRequest().authenticated() ) // ── 2. CONFIGURATION DU FORMULAIRE DE LOGIN ───────────────────── .formLogin(form -> form .loginPage("/login") // Notre page de login personnalisée .loginProcessingUrl("/login") // URL qui traite le formulaire POST .defaultSuccessUrl("/", true) // Redirection après succès .failureUrl("/login?error") // Redirection si échec .permitAll() // La page login est accessible à tous ) // ── 3. CONFIGURATION DE LA DÉCONNEXION ────────────────────────── .logout(logout -> logout .logoutUrl("/logout") // URL de déconnexion .logoutSuccessUrl("/login?logout") // Redirection après logout .invalidateHttpSession(true) // Invalider la session .deleteCookies("JSESSIONID") // Supprimer le cookie de session .permitAll() ); return http.build(); } /** * L'encodeur de mots de passe. * BCrypt est l'algorithme recommandé — fort et adaptatif. * JAMAIS de NoOpPasswordEncoder en production ! */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); // Le facteur de coût par défaut est 10 (~100ms par hash) // Pour plus de sécurité : new BCryptPasswordEncoder(12) (~400ms) } }
Décryptage de la méthode :
@Configuration : indique à Spring que cette classe contient des beans @EnableWebSecurity : active le support Spring Security @Bean sur filterChain : enregistre notre configuration comme la configuration de sécurité HttpSecurity http : l’objet builder qui permet de configurer la chaîne de filtres http.build() : construit et retourne la chaîne de filtres finale
@Configuration
@EnableWebSecurity
@Bean
filterChain
HttpSecurity http
http.build()
Pour tester sans base de données, on peut créer des utilisateurs directement en mémoire :
// Dans SecurityConfig.java /** * UserDetailsManager en mémoire — UNIQUEMENT pour les tests/démos. * En production, on utilise UserDetailsService avec une BDD. */ @Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { // Créer un utilisateur standard UserDetails userAlice = User.builder() .username("alice") .password(encoder.encode("alice123")) // TOUJOURS encoder ! .roles("USER") // Ajoute ROLE_USER automatiquement .build(); // Créer un administrateur UserDetails userAdmin = User.builder() .username("admin") .password(encoder.encode("admin123")) .roles("USER", "ADMIN") // Peut avoir plusieurs rôles .build(); // Créer un utilisateur désactivé UserDetails userBob = User.builder() .username("bob") .password(encoder.encode("bob123")) .roles("USER") .disabled(true) // Compte désactivé .build(); return new InMemoryUserDetailsManager(userAlice, userAdmin, userBob); }
InMemoryUserDetailsManager est UNIQUEMENT pour les tests. Les utilisateurs sont perdus au redémarrage. Le chapitre 5 montre comment utiliser une vraie base de données.
InMemoryUserDetailsManager
<!-- templates/login.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <head> <meta charset="UTF-8"> <title>Connexion</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"> </head> <body class="bg-light"> <div class="container mt-5"> <div class="row justify-content-center"> <div class="col-md-4"> <div class="card shadow"> <div class="card-header bg-dark text-white text-center"> <h4> Connexion</h4> </div> <div class="card-body p-4"> <!-- Spring Security lit automatiquement ?error et ?logout dans l'URL pour afficher les messages correspondants. Ces paramètres sont ajoutés par failureUrl et logoutSuccessUrl. --> <!-- Affiché quand l'URL contient ?error --> <div th:if="${param.error}" class="alert alert-danger"> <strong>Erreur !</strong> Identifiants incorrects. </div> <!-- Affiché quand l'URL contient ?logout --> <div th:if="${param.logout}" class="alert alert-success"> Vous avez été déconnecté avec succès. </div> <!-- IMPORTANT : l'action du formulaire DOIT correspondre à loginProcessingUrl() dans la configuration Security. Ici : "/login" en POST. Spring Security intercepte ce POST et gère l'authentification. Votre controller n'a PAS besoin de gérer ce POST ! --> <form th:action="@{/login}" method="post"> <!-- Le token CSRF est injecté automatiquement par Thymeleaf quand Spring Security est actif. Sans lui, le POST serait refusé (protection CSRF). --> <div class="mb-3"> <!-- Le name DOIT être "username" (ou le paramètre configuré dans usernameParameter() de formLogin). --> <label for="username" class="form-label">Email / Identifiant</label> <input type="text" class="form-control" id="username" name="username" placeholder="votre@email.com" required autofocus> </div> <div class="mb-3"> <!-- Le name DOIT être "password" --> <label for="password" class="form-label">Mot de passe</label> <input type="password" class="form-control" id="password" name="password" placeholder="••••••••" required> </div> <div class="mb-3 form-check"> <!-- "Remember Me" : Spring Security peut se souvenir de l'utilisateur via un cookie persistant. Nécessite rememberMe() dans la config. --> <input type="checkbox" class="form-check-input" id="remember-me" name="remember-me"> <label class="form-check-label" for="remember-me"> Se souvenir de moi </label> </div> <button type="submit" class="btn btn-dark w-100"> Se connecter </button> </form> <hr> <div class="text-center"> <a th:href="@{/inscription}">Pas encore de compte ? S'inscrire</a> </div> </div> </div> </div> </div> </div> </body> </html>
// controller/AuthController.java @Controller public class AuthController { /** * Affiche la page de login. * On gère UNIQUEMENT le GET /login — l'affichage du formulaire. * Le POST /login est géré AUTOMATIQUEMENT par Spring Security. * N'écrivez JAMAIS de @PostMapping("/login") ici ! */ @GetMapping("/login") public String afficherLogin() { return "login"; } // NE PAS FAIRE — Spring Security gère déjà le POST /login // @PostMapping("/login") // public String traiterLogin(...) { ... } // INUTILE et conflictueux ! }
// domain/Utilisateur.java @Entity @Table(name = "utilisateurs") @Getter @Setter @NoArgsConstructor public class Utilisateur { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) @Email private String email; @Column(nullable = false) private String nom; @Column(nullable = false) private String prenom; /** * Le mot de passe est TOUJOURS stocké hashé (BCrypt). * La colonne est suffisamment longue pour un hash BCrypt (60 caractères). */ @Column(nullable = false, length = 100) private String motDePasse; /** * Le rôle de l'utilisateur. * Stocké comme String en base (ROLE_USER, ROLE_ADMIN...). */ @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role = Role.ROLE_USER; /** * Compte actif ou non. * Permet de désactiver un compte sans le supprimer. */ @Column(nullable = false) private boolean actif = true; @Column(nullable = false) private LocalDateTime dateCreation = LocalDateTime.now(); public Utilisateur(String email, String nom, String prenom, String motDePasse, Role role) { this.email = email; this.nom = nom; this.prenom = prenom; this.motDePasse = motDePasse; this.role = role; } public String getNomComplet() { return prenom + " " + nom; } }
// domain/Role.java public enum Role { ROLE_USER, ROLE_ADMIN, ROLE_MODERATEUR; /** * Retourne le rôle sans le préfixe ROLE_ pour l'affichage. * Ex: ROLE_ADMIN → "ADMIN" */ public String getLibelle() { return this.name().replace("ROLE_", ""); } }
// repository/UtilisateurRepository.java @Repository public interface UtilisateurRepository extends JpaRepository<Utilisateur, Long> { /** * Charge un utilisateur par son email. * Utilisé par Spring Security lors de l'authentification. */ Optional<Utilisateur> findByEmail(String email); /** * Vérifie si un email est déjà utilisé. * Utilisé lors de l'inscription pour éviter les doublons. */ boolean existsByEmail(String email); /** * Charge tous les utilisateurs avec un rôle spécifique. */ List<Utilisateur> findByRole(Role role); }
C’est la classe la plus importante pour comprendre comment Spring Security trouve vos utilisateurs en base :
// service/CustomUserDetailsService.java @Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final UtilisateurRepository utilisateurRepository; /** * MÉTHODE CENTRALE — Spring Security appelle cette méthode * à CHAQUE tentative de connexion. * * Le processus complet lors d'un login : * 1. L'utilisateur soumet email + motDePasse * 2. Spring Security appelle loadUserByUsername(email) * 3. On cherche l'utilisateur en BDD par son email * 4. On retourne un objet UserDetails avec le hash du mot de passe * 5. Spring Security compare le motDePasse fourni avec le hash * via passwordEncoder.matches(motDePasse, hash) * 6. Si OK → authentification réussie → SecurityContext mis à jour * * @param email l'identifiant saisi dans le formulaire (ici l'email) * @return un UserDetails représentant l'utilisateur trouvé * @throws UsernameNotFoundException si aucun utilisateur trouvé */ @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { // 1. Chercher l'utilisateur en base de données Utilisateur utilisateur = utilisateurRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException( "Aucun utilisateur trouvé avec l'email : " + email // NOTE DE SÉCURITÉ : En production, évitez de révéler // si c'est l'email ou le mot de passe qui est incorrect // pour ne pas aider les attaquants (enumeration attack). // Affichez toujours "Identifiants incorrects" côté utilisateur. )); // 2. Convertir notre entité en UserDetails Spring Security return User.builder() .username(utilisateur.getEmail()) .password(utilisateur.getMotDePasse()) // Déjà hashé en BDD .authorities(utilisateur.getRole().name()) // "ROLE_USER", "ROLE_ADMIN" .accountLocked(!utilisateur.isActif()) // Compte verrouillé si inactif .disabled(!utilisateur.isActif()) // Désactivé si inactif .build(); } }
Pourquoi on compare les mots de passe sans le faire nous-mêmes ? Spring Security appelle passwordEncoder.matches(motDePasseFourni, hashEnBDD). C’est Spring Security qui fait la comparaison, pas nous. Notre rôle est juste de retourner l’entité avec le hash stocké en BDD.
passwordEncoder.matches(motDePasseFourni, hashEnBDD)
// service/UtilisateurService.java @Service @Transactional @RequiredArgsConstructor public class UtilisateurService { private final UtilisateurRepository repository; private final PasswordEncoder passwordEncoder; /** * Inscrit un nouvel utilisateur. * Le mot de passe est hashé AVANT la sauvegarde. */ public Utilisateur inscrire(InscriptionDTO dto) { // Vérifier que l'email n'est pas déjà utilisé if (repository.existsByEmail(dto.getEmail())) { throw new EmailDejaUtiliseException("Email déjà utilisé : " + dto.getEmail()); } // Créer l'entité avec le mot de passe HASHÉ Utilisateur utilisateur = new Utilisateur( dto.getEmail(), dto.getNom(), dto.getPrenom(), passwordEncoder.encode(dto.getMotDePasse()), // ← HASH ICI Role.ROLE_USER // Par défaut, tout nouvel utilisateur est ROLE_USER ); return repository.save(utilisateur); } /** * Récupère l'utilisateur actuellement connecté. * Utilise le SecurityContext pour obtenir l'email, puis charge depuis la BDD. */ @Transactional(readOnly = true) public Utilisateur getUtilisateurConnecte() { // Récupérer l'email depuis le SecurityContext String email = SecurityContextHolder.getContext() .getAuthentication() .getName(); return repository.findByEmail(email) .orElseThrow(() -> new RuntimeException("Utilisateur connecté introuvable")); } /** * Change le mot de passe d'un utilisateur. * Vérifie l'ancien mot de passe avant le changement. */ public void changerMotDePasse(String email, String ancienMdp, String nouveauMdp) { Utilisateur utilisateur = repository.findByEmail(email) .orElseThrow(() -> new RuntimeException("Utilisateur introuvable")); // Vérifier l'ancien mot de passe if (!passwordEncoder.matches(ancienMdp, utilisateur.getMotDePasse())) { throw new MotDePasseIncorrectException("Ancien mot de passe incorrect"); } // Sauvegarder le nouveau mot de passe hashé utilisateur.setMotDePasse(passwordEncoder.encode(nouveauMdp)); repository.save(utilisateur); } @Transactional(readOnly = true) public List<Utilisateur> findAll() { return repository.findAll(); } public void desactiver(Long id) { Utilisateur u = repository.findById(id) .orElseThrow(() -> new RuntimeException("Utilisateur introuvable")); u.setActif(false); repository.save(u); } }
// config/SecurityConfig.java — VERSION COMPLÈTE AVEC BDD @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { /** * Spring injecte automatiquement notre CustomUserDetailsService * car il implémente UserDetailsService et est annoté @Service. */ private final CustomUserDetailsService userDetailsService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/accueil").permitAll() .requestMatchers("/css/**", "/js/**", "/images/**").permitAll() .requestMatchers("/login", "/inscription").permitAll() .requestMatchers("/h2-console/**").permitAll() // Accès console H2 en dev .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .usernameParameter("email") // Notre champ s'appelle "email" .passwordParameter("password") // Standard, on peut le changer .defaultSuccessUrl("/", true) .failureUrl("/login?error") .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") .permitAll() ) .rememberMe(remember -> remember .key("cle-secrete-formation-2024") // Clé de signature du cookie .tokenValiditySeconds(7 * 24 * 3600) // 7 jours .userDetailsService(userDetailsService) ) // Nécessaire pour la console H2 en développement .csrf(csrf -> csrf .ignoringRequestMatchers("/h2-console/**") ) .headers(headers -> headers .frameOptions(frame -> frame.sameOrigin()) // Pour la console H2 ); return http.build(); } /** * Configure le fournisseur d'authentification. * On lui dit d'utiliser notre UserDetailsService ET notre encodeur. */ @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder()); return provider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * Expose l'AuthenticationManager comme bean. * Nécessaire pour l'authentification programmatique (ex: dans les tests * ou dans le flow d'inscription avec connexion automatique). */ @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } }
// dto/InscriptionDTO.java @Getter @Setter public class InscriptionDTO { @NotBlank(message = "Le prénom est obligatoire") private String prenom; @NotBlank(message = "Le nom est obligatoire") private String nom; @NotBlank(message = "L'email est obligatoire") @Email(message = "L'email n'est pas valide") private String email; @NotBlank(message = "Le mot de passe est obligatoire") @Size(min = 8, message = "Le mot de passe doit faire au moins 8 caractères") private String motDePasse; @NotBlank(message = "La confirmation est obligatoire") private String confirmationMotDePasse; /** * Validation personnalisée : les deux mots de passe doivent correspondre. * On fait cette vérification au niveau du service ou du controller. */ public boolean motDePasseCorrespond() { return motDePasse != null && motDePasse.equals(confirmationMotDePasse); } }
// controller/AuthController.java @Controller @RequiredArgsConstructor public class AuthController { private final UtilisateurService utilisateurService; private final AuthenticationManager authenticationManager; @GetMapping("/login") public String afficherLogin() { return "login"; } @GetMapping("/inscription") public String afficherInscription(Model model) { model.addAttribute("inscriptionDTO", new InscriptionDTO()); return "inscription"; } @PostMapping("/inscription") public String traiterInscription( @Valid @ModelAttribute("inscriptionDTO") InscriptionDTO dto, BindingResult bindingResult, Model model, HttpServletRequest request, HttpServletResponse response) { // Vérification des erreurs de validation Bean Validation if (bindingResult.hasErrors()) { return "inscription"; } // Vérification personnalisée : les mots de passe correspondent-ils ? if (!dto.motDePasseCorrespond()) { bindingResult.rejectValue("confirmationMotDePasse", "error.dto", "Les mots de passe ne correspondent pas"); return "inscription"; } try { // Créer l'utilisateur en base utilisateurService.inscrire(dto); // Connecter automatiquement l'utilisateur après inscription // (meilleure UX que de rediriger vers le login) UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(dto.getEmail(), dto.getMotDePasse()); Authentication auth = authenticationManager.authenticate(authToken); SecurityContextHolder.getContext().setAuthentication(auth); return "redirect:/"; } catch (EmailDejaUtiliseException e) { bindingResult.rejectValue("email", "error.dto", e.getMessage()); return "inscription"; } } }
// config/DataInitializer.java @Configuration @RequiredArgsConstructor @Slf4j public class DataInitializer { private final UtilisateurRepository repository; private final PasswordEncoder passwordEncoder; @Bean CommandLineRunner initData() { return args -> { if (repository.count() > 0) return; // Créer un administrateur repository.save(new Utilisateur( "admin@formation.fr", "Martin", "Alice", passwordEncoder.encode("Admin1234!"), Role.ROLE_ADMIN )); // Créer des utilisateurs standards repository.save(new Utilisateur( "user@formation.fr", "Dupont", "Bob", passwordEncoder.encode("User1234!"), Role.ROLE_USER )); repository.save(new Utilisateur( "charlie@formation.fr", "Durand", "Charlie", passwordEncoder.encode("Charlie1234!"), Role.ROLE_USER )); log.info(" Données de démo initialisées :"); log.info(" admin@formation.fr / Admin1234!"); log.info(" user@formation.fr / User1234!"); }; } }
C’est la méthode la plus simple. Elle s’applique à toutes les requêtes vers une URL.
http.authorizeHttpRequests(auth -> auth // ── ACCÈS PUBLIC ────────────────────────────────────────── .requestMatchers("/").permitAll() .requestMatchers("/login", "/inscription").permitAll() // Wildcards : ** = n'importe quoi (y compris /) .requestMatchers("/css/**", "/js/**", "/images/**").permitAll() // ── ACCÈS PAR RÔLE ─────────────────────────────────────── // Un seul rôle requis .requestMatchers("/admin/**").hasRole("ADMIN") // ATTENTION : hasRole("ADMIN") cherche "ROLE_ADMIN" en interne // Plusieurs rôles acceptés (OR logique) .requestMatchers("/moderation/**").hasAnyRole("ADMIN", "MODERATEUR") // Authority spécifique (sans préfixe ROLE_) .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") // ── ACCÈS AUTHENTIFIÉ ──────────────────────────────────── // Doit être connecté, quel que soit le rôle .requestMatchers("/profil/**").authenticated() // ── ACCÈS PAR MÉTHODE HTTP ─────────────────────────────── .requestMatchers(HttpMethod.GET, "/api/livres/**").authenticated() .requestMatchers(HttpMethod.POST, "/api/livres/**").hasRole("ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/livres/**").hasRole("ADMIN") // ── ORDRE IMPORTANT ────────────────────────────────────── // Les règles sont évaluées DANS L'ORDRE. // Mettez les règles les plus spécifiques AVANT les plus générales. // Toutes les autres requêtes nécessitent une authentification .anyRequest().authenticated() );
@EnableMethodSecurity permet d’utiliser des annotations directement sur les méthodes des services et controllers. C’est plus fin et plus flexible.
@EnableMethodSecurity
// config/SecurityConfig.java — Ajouter l'annotation @Configuration @EnableWebSecurity @EnableMethodSecurity( // Active les annotations de sécurité sur les méthodes prePostEnabled = true, // @PreAuthorize, @PostAuthorize securedEnabled = true // @Secured ) public class SecurityConfig { ... }
// service/UtilisateurService.java @Service public class UtilisateurService { /** * @PreAuthorize : vérifie les droits AVANT l'exécution de la méthode. * Si non autorisé → AccessDeniedException (403). */ @PreAuthorize("hasRole('ADMIN')") public List<Utilisateur> findAll() { return repository.findAll(); } /** * Vérification plus complexe avec SpEL (Spring Expression Language). * L'utilisateur peut accéder à son propre profil OU être admin. * * authentication = l'objet Authentication du SecurityContext * #id = le paramètre de la méthode nommé "id" */ @PreAuthorize("hasRole('ADMIN') or #email == authentication.name") public Utilisateur findByEmail(String email) { return repository.findByEmail(email).orElseThrow(); } /** * @PostAuthorize : vérifie APRÈS l'exécution. * returnObject = la valeur retournée par la méthode. * Utile pour vérifier sur l'objet retourné. */ @PostAuthorize("returnObject.email == authentication.name or hasRole('ADMIN')") public Utilisateur findById(Long id) { return repository.findById(id).orElseThrow(); } /** * @Secured : plus simple que @PreAuthorize, mais moins flexible. * Accepte une liste de rôles (avec le préfixe ROLE_). */ @Secured({"ROLE_ADMIN", "ROLE_MODERATEUR"}) public void modererContenu(Long contenuId) { // ... } }
// controller/AdminController.java @Controller @RequestMapping("/admin") @PreAuthorize("hasRole('ADMIN')") // Toutes les méthodes de ce controller public class AdminController { private final UtilisateurService utilisateurService; @GetMapping("/dashboard") public String dashboard(Model model) { model.addAttribute("utilisateurs", utilisateurService.findAll()); return "admin/dashboard"; } @PostMapping("/utilisateurs/{id}/desactiver") public String desactiver(@PathVariable Long id, RedirectAttributes ra) { utilisateurService.desactiver(id); ra.addFlashAttribute("successMessage", "Utilisateur désactivé"); return "redirect:/admin/dashboard"; } }
// controller/UserController.java @Controller @RequestMapping("/user") public class UserController { private final UtilisateurService utilisateurService; /** * Récupère l'utilisateur connecté depuis le Principal. * Spring MVC injecte automatiquement le Principal (= l'Authentication). */ @GetMapping("/profil") public String profil(Model model, Principal principal) { // principal.getName() retourne le username (ici l'email) Utilisateur utilisateur = utilisateurService .findByEmail(principal.getName()); model.addAttribute("utilisateur", utilisateur); return "user/profil"; } /** * Alternative : injecter directement l'Authentication. */ @GetMapping("/profil-alt") public String profilAlt(Model model, Authentication authentication) { String email = authentication.getName(); // authentication.getAuthorities() → les rôles // authentication.getPrincipal() → l'objet UserDetails model.addAttribute("utilisateur", utilisateurService.findByEmail(email)); return "user/profil"; } /** * Autre alternative : @AuthenticationPrincipal * Injecte directement l'objet UserDetails. */ @GetMapping("/profil-v3") public String profilV3(Model model, @AuthenticationPrincipal UserDetails userDetails) { String email = userDetails.getUsername(); model.addAttribute("utilisateur", utilisateurService.findByEmail(email)); return "user/profil"; } }
// Dans SecurityConfig.java — Configurer les pages d'erreur http.exceptionHandling(ex -> ex // Page affichée quand un utilisateur non connecté tente d'accéder // à une ressource protégée (401) .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) // Page affichée quand un utilisateur connecté n'a pas les droits (403) .accessDeniedPage("/acces-refuse") );
// controller/AccueilController.java @GetMapping("/acces-refuse") public String accesRefuse() { return "error/403"; }
<!-- templates/error/403.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <head><title>Accès refusé</title></head> <body> <div class="container text-center mt-5"> <h1 class="display-1 text-danger">403</h1> <h2>Accès refusé</h2> <p>Vous n'avez pas les permissions nécessaires pour accéder à cette page.</p> <a th:href="@{/}" class="btn btn-primary">Retour à l'accueil</a> </div> </body> </html>
CSRF (Cross-Site Request Forgery = Falsification de requête inter-sites) est une attaque qui force un utilisateur authentifié à exécuter des actions à son insu.
Exemple d’attaque CSRF :
1. Alicia est connectée à sa banque (monsite.com) 2. Alicia visite un site malveillant (mechant.com) 3. mechant.com contient un formulaire caché : <form action="https://monsite.com/virement" method="post"> <input type="hidden" name="destinataire" value="Pirate"> <input type="hidden" name="montant" value="1000"> </form> <script>document.forms[0].submit();</script> 4. Le formulaire s'envoie automatiquement avec le cookie de session d'Alice 5. La banque croit que c'est Alice qui fait le virement → PROBLÈME !
Spring Security protège contre le CSRF en générant un token unique par session. Chaque formulaire POST doit inclure ce token, et Spring Security le vérifie.
1. Le serveur génère un token CSRF aléatoire pour la session 2. Ce token est inclus dans chaque formulaire HTML 3. Lors du POST, Spring Security vérifie que le token est correct 4. mechant.com ne peut pas connaître ce token (même origine uniquement) → L'attaque CSRF est bloquée !
Avec Thymeleaf, c’est automatique :
<!-- Thymeleaf injecte automatiquement le token CSRF dans les formulaires POST --> <form th:action="@{/inscription}" method="post"> <!-- Thymeleaf ajoute automatiquement : --> <!-- <input type="hidden" name="_csrf" value="a3f2b1c9-..."> --> ... </form>
Avec JavaScript (fetch/axios) :
<!-- Dans le <head>, exposer le token pour JavaScript --> <meta name="_csrf" th:content="${_csrf.token}"> <meta name="_csrf_header" th:content="${_csrf.headerName}">
// Récupérer le token const csrfToken = document.querySelector('meta[name="_csrf"]').content; const csrfHeader = document.querySelector('meta[name="_csrf_header"]').content; // L'inclure dans les requêtes fetch fetch('/api/livres', { method: 'POST', headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken // "X-CSRF-TOKEN": "a3f2b1c9-..." }, body: JSON.stringify(livre) });
Pour les APIs REST stateless (avec JWT), le CSRF n’est pas nécessaire car il n’y a pas de cookies de session. On le désactive :
// Dans SecurityConfig pour une API REST pure http .csrf(csrf -> csrf.disable()) // Pas de CSRF pour les APIs stateless .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // Pas de session );
Spring Security ajoute automatiquement des headers de sécurité HTTP à chaque réponse :
http.headers(headers -> headers // Protection contre le Clickjacking (iframes malveillantes) .frameOptions(frame -> frame.deny()) // ou .sameOrigin() pour autoriser les iframes du même domaine (ex: H2 console) // Force HTTPS (HSTS) .httpStrictTransportSecurity(hsts -> hsts .maxAgeInSeconds(31536000) // 1 an .includeSubDomains(true) ) // Empêche le browser de deviner le Content-Type .contentTypeOptions(Customizer.withDefaults()) // Protection XSS (pour les vieux browsers) .xssProtection(Customizer.withDefaults()) // Content Security Policy (CSP) — contrôle les ressources chargées .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; script-src 'self' cdn.jsdelivr.net") ) );
Pour utiliser les fonctionnalités Spring Security dans Thymeleaf, ajoutez le namespace :
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" lang="fr">
<!-- Afficher selon le statut de connexion --> <div sec:authorize="isAnonymous()"> <!-- Visible UNIQUEMENT pour les visiteurs non connectés --> <a th:href="@{/login}" class="btn btn-primary">Se connecter</a> <a th:href="@{/inscription}" class="btn btn-outline-primary">S'inscrire</a> </div> <div sec:authorize="isAuthenticated()"> <!-- Visible UNIQUEMENT pour les utilisateurs connectés --> <span>Bonjour, <strong sec:authentication="name">Utilisateur</strong> !</span> <form th:action="@{/logout}" method="post" class="d-inline"> <button type="submit" class="btn btn-outline-danger btn-sm">Déconnexion</button> </form> </div> <!-- Afficher selon le rôle --> <div sec:authorize="hasRole('ADMIN')"> <!-- Visible UNIQUEMENT pour les administrateurs --> <a th:href="@{/admin/dashboard}" class="btn btn-warning"> Administration </a> </div> <div sec:authorize="hasAnyRole('ADMIN', 'MODERATEUR')"> <!-- Visible pour les admins ET les modérateurs --> <a th:href="@{/moderation}">Modération</a> </div> <div sec:authorize="!hasRole('ADMIN')"> <!-- Visible pour tous SAUF les admins --> <p>Contenu pour utilisateurs standards</p> </div>
<!-- Nom d'utilisateur (username = email dans notre cas) --> <span sec:authentication="name">email@exemple.com</span> <!-- Rôles de l'utilisateur --> <span sec:authentication="principal.authorities">[ROLE_USER]</span> <!-- Principal complet (objet UserDetails) --> <span th:text="${#authentication.name}">Utilisateur</span> <!-- Dans Thymeleaf, on peut aussi utiliser #authentication --> <p th:text="'Connecté en tant que : ' + ${#authentication.name}"> Connecté </p>
<!-- templates/fragments/layout.html --> <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <div class="container"> <a class="navbar-brand" th:href="@{/}"> SecureApp</a> <div class="navbar-nav ms-auto"> <!-- Liens visibles pour tous --> <a class="nav-link" th:href="@{/}">Accueil</a> <!-- Liens réservés aux utilisateurs connectés --> <a class="nav-link" th:href="@{/user/profil}" sec:authorize="isAuthenticated()"> Mon profil </a> <!-- Liens réservés aux admins --> <a class="nav-link text-warning" th:href="@{/admin/dashboard}" sec:authorize="hasRole('ADMIN')"> Admin </a> <!-- Bouton de déconnexion (utilisateurs connectés) --> <span sec:authorize="isAuthenticated()"> <span class="navbar-text me-3"> 👤 <span sec:authentication="name"></span> </span> <form th:action="@{/logout}" method="post" class="d-inline"> <button class="btn btn-outline-light btn-sm" type="submit"> Déconnexion </button> </form> </span> <!-- Bouton de connexion (visiteurs) --> <a class="btn btn-light btn-sm" th:href="@{/login}" sec:authorize="isAnonymous()"> Se connecter </a> </div> </div> </nav>
Pour les applications frontend séparées (React, Vue, Angular) ou les APIs REST consommées par des mobiles, on ne peut pas utiliser les sessions et les cookies traditionnels. On utilise alors des JWT (JSON Web Tokens).
Architecture avec sessions (applications web traditionnelles) : Client → [formulaire login] → Serveur → [session en mémoire] → [cookie JSESSIONID] Architecture avec JWT (APIs REST + SPA) : Client → [POST /api/auth/login {email, password}] → Serveur → [JWT token] Client → [GET /api/ressources] avec header "Authorization: Bearer <JWT>" → Serveur
Un JWT est une chaîne encodée en Base64 composée de 3 parties séparées par des points :
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZUBleGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDg2NDAwLCJyb2xlIjoiUk9MRV9VU0VSIn0.signature │ │ │ HEADER PAYLOAD SIGNATURE (algorithme) (données/claims) (vérification d'intégrité)
Header : {"alg":"HS256","typ":"JWT"} Payload : {"sub":"alice@example.com","iat":1700000000,"exp":1700086400,"role":"ROLE_USER"} Signature : HMACSHA256(header + “.” + payload, secret)
{"alg":"HS256","typ":"JWT"}
{"sub":"alice@example.com","iat":1700000000,"exp":1700086400,"role":"ROLE_USER"}
** Un JWT est encodé (Base64), pas chiffré !** N’y mettez jamais de données sensibles (mot de passe, numéro de carte bancaire…). Tout le monde peut décoder le payload.
// service/JwtService.java @Service public class JwtService { // La clé secrète — DOIT être dans application.properties ou variable d'env // En production : au minimum 256 bits (32 caractères) @Value("${app.jwt.secret}") private String secretKey; @Value("${app.jwt.expiration-ms}") private long expirationMs; // 86400000 = 24 heures /** * Génère un JWT pour un utilisateur authentifié. * * @param userDetails L'utilisateur pour qui générer le token * @return Le JWT sous forme de String */ public String genererToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); // On peut ajouter des données supplémentaires dans le token (claims) claims.put("roles", userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); return Jwts.builder() .claims(claims) .subject(userDetails.getUsername()) // L'email .issuedAt(new Date()) // Date de création .expiration(new Date(System.currentTimeMillis() + expirationMs)) // Expiration .signWith(getSigningKey()) // Signature avec notre clé secrète .compact(); // Construire le token } /** * Extrait le username (email) depuis un JWT. */ public String extraireUsername(String token) { return extraireClaim(token, Claims::getSubject); } /** * Vérifie si un JWT est valide (non expiré + signature correcte). */ public boolean estValide(String token, UserDetails userDetails) { String username = extraireUsername(token); return username.equals(userDetails.getUsername()) && !estExpire(token); } // ── Méthodes privées ───────────────────────────────────────────────────── private boolean estExpire(String token) { return extraireClaim(token, Claims::getExpiration).before(new Date()); } private <T> T extraireClaim(String token, Function<Claims, T> resolver) { Claims claims = extraireTousClaims(token); return resolver.apply(claims); } private Claims extraireTousClaims(String token) { return Jwts.parser() .verifyWith(getSigningKey()) .build() .parseSignedClaims(token) .getPayload(); } private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } }
// config/JwtAuthenticationFilter.java @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final CustomUserDetailsService userDetailsService; /** * Ce filtre est exécuté UNE FOIS par requête (OncePerRequestFilter). * Il cherche un JWT dans le header Authorization, le valide, * et met à jour le SecurityContext si valide. */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 1. Lire le header Authorization String authHeader = request.getHeader("Authorization"); // 2. Vérifier que le header est présent et commence par "Bearer " if (authHeader == null || !authHeader.startsWith("Bearer ")) { // Pas de JWT → passer au filtre suivant sans rien faire filterChain.doFilter(request, response); return; } // 3. Extraire le token (supprimer "Bearer ") String jwt = authHeader.substring(7); // 4. Extraire le username depuis le token String email = jwtService.extraireUsername(jwt); // 5. Si on a un email ET que l'utilisateur n'est pas déjà authentifié if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 6. Charger l'utilisateur depuis la BDD UserDetails userDetails = userDetailsService.loadUserByUsername(email); // 7. Valider le token if (jwtService.estValide(jwt, userDetails)) { // 8. Créer l'objet Authentication et le mettre dans le SecurityContext UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); // Ajouter les détails de la requête (IP, user-agent…) authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); // *** C'EST LÀ QUE L'AUTHENTIFICATION SE FAIT *** SecurityContextHolder.getContext().setAuthentication(authToken); } } // 9. Continuer la chaîne de filtres filterChain.doFilter(request, response); } }
// Deuxième SecurityFilterChain — pour les APIs REST @Bean @Order(1) // Priorité plus haute que le filterChain principal public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http // S'applique uniquement aux URLs /api/** .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() // Login/register publics .requestMatchers(HttpMethod.GET, "/api/livres/**").authenticated() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) // Pas de formulaire de login (API REST) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) // Pas de CSRF pour les APIs stateless .csrf(csrf -> csrf.disable()) // Ajouter notre filtre JWT AVANT le filtre d'authentification standard .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) // Gestion des erreurs pour les APIs (JSON, pas redirect) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, e) -> { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(""" {"erreur": "Non authentifié", "message": "Token JWT requis"} """); }) .accessDeniedHandler((request, response, e) -> { response.setStatus(403); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(""" {"erreur": "Accès refusé", "message": "Droits insuffisants"} """); }) ); return http.build(); }
// controller/ApiAuthController.java @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class ApiAuthController { private final AuthenticationManager authenticationManager; private final JwtService jwtService; private final CustomUserDetailsService userDetailsService; private final UtilisateurService utilisateurService; /** * Endpoint de login : POST /api/auth/login * Corps : {"email": "alice@test.com", "password": "motdepasse"} * Retourne : {"token": "eyJ...", "type": "Bearer", "email": "..."} */ @PostMapping("/login") public ResponseEntity<?> login(@RequestBody @Valid LoginDTO loginDTO) { try { // Authentifier l'utilisateur Authentication auth = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginDTO.getEmail(), loginDTO.getMotDePasse() ) ); // Charger les détails pour générer le JWT UserDetails userDetails = userDetailsService .loadUserByUsername(loginDTO.getEmail()); // Générer le JWT String jwt = jwtService.genererToken(userDetails); return ResponseEntity.ok(Map.of( "token", jwt, "type", "Bearer", "email", loginDTO.getEmail(), "expires_in", "24h" )); } catch (AuthenticationException e) { return ResponseEntity.status(401).body(Map.of( "erreur", "Authentification échouée", "message", "Email ou mot de passe incorrect" )); } } /** * Endpoint d'inscription API : POST /api/auth/inscription */ @PostMapping("/inscription") public ResponseEntity<?> inscription(@RequestBody @Valid InscriptionDTO dto) { try { Utilisateur nouvelUtilisateur = utilisateurService.inscrire(dto); return ResponseEntity.status(201).body(Map.of( "message", "Compte créé avec succès", "email", nouvelUtilisateur.getEmail() )); } catch (EmailDejaUtiliseException e) { return ResponseEntity.status(409).body(Map.of( "erreur", "Email déjà utilisé" )); } } }
// test/SecurityTest.java @SpringBootTest @AutoConfigureMockMvc class SecurityTest { @Autowired private MockMvc mockMvc; // ── TESTS D'ACCÈS ────────────────────────────────────────────────────── @Test @DisplayName("La page d'accueil est accessible sans connexion") void accueil_accessible_sans_connexion() throws Exception { mockMvc.perform(get("/")) .andExpect(status().isOk()); } @Test @DisplayName("Le dashboard admin redirige vers login si non connecté") void admin_redirige_vers_login_si_non_connecte() throws Exception { mockMvc.perform(get("/admin/dashboard")) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrlPattern("**/login")); } @Test @DisplayName("Un utilisateur standard ne peut pas accéder à /admin") @WithMockUser(username = "user@test.com", roles = {"USER"}) void admin_refuse_pour_role_user() throws Exception { mockMvc.perform(get("/admin/dashboard")) .andExpect(status().isForbidden()); } @Test @DisplayName("Un admin peut accéder au dashboard") @WithMockUser(username = "admin@test.com", roles = {"ADMIN"}) void admin_accessible_pour_role_admin() throws Exception { mockMvc.perform(get("/admin/dashboard")) .andExpect(status().isOk()); } // ── TESTS DE LOGIN ───────────────────────────────────────────────────── @Test @DisplayName("La page de login est accessible sans connexion") void page_login_accessible() throws Exception { mockMvc.perform(get("/login")) .andExpect(status().isOk()) .andExpect(view().name("login")); } @Test @DisplayName("Le login avec de bons identifiants redirige vers l'accueil") void login_avec_bons_identifiants_redirige() throws Exception { mockMvc.perform(post("/login") .param("email", "user@formation.fr") .param("password", "User1234!") .with(csrf())) // Inclure le token CSRF dans le test ! .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/")); } @Test @DisplayName("Le login avec de mauvais identifiants redirige vers /login?error") void login_avec_mauvais_identifiants_echoue() throws Exception { mockMvc.perform(post("/login") .param("email", "user@formation.fr") .param("password", "mauvaismdp") .with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/login?error")); } // ── TESTS CSRF ───────────────────────────────────────────────────────── @Test @DisplayName("Un POST sans token CSRF est refusé (403)") @WithMockUser void post_sans_csrf_refuse() throws Exception { mockMvc.perform(post("/user/profil")) .andExpect(status().isForbidden()); // Sans .with(csrf()), Spring Security retourne 403 } }
@SpringBootTest class UtilisateurServiceTest { @Autowired private UtilisateurService utilisateurService; @Test @DisplayName("Un non-admin ne peut pas lister tous les utilisateurs") @WithUserDetails(value = "user@formation.fr") void lister_utilisateurs_refuse_pour_user() { assertThrows(AccessDeniedException.class, () -> utilisateurService.findAll() ); } @Test @DisplayName("Un admin peut lister tous les utilisateurs") @WithUserDetails(value = "admin@formation.fr") void lister_utilisateurs_autorise_pour_admin() { assertDoesNotThrow(() -> { List<Utilisateur> users = utilisateurService.findAll(); assertThat(users).isNotEmpty(); }); } }
@SpringBootTest @AutoConfigureMockMvc class ApiAuthControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("Le login API retourne un JWT valide") void login_api_retourne_jwt() throws Exception { LoginDTO loginDTO = new LoginDTO("admin@formation.fr", "Admin1234!"); MvcResult result = mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(loginDTO))) .andExpect(status().isOk()) .andExpect(jsonPath("$.token").exists()) .andExpect(jsonPath("$.type").value("Bearer")) .andReturn(); // Extraire le token pour les tests suivants String responseBody = result.getResponse().getContentAsString(); String token = objectMapper.readTree(responseBody).get("token").asText(); assertThat(token).isNotBlank(); } @Test @DisplayName("Un endpoint protégé est accessible avec un JWT valide") void endpoint_protege_accessible_avec_jwt() throws Exception { String token = obtenirTokenValide(); mockMvc.perform(get("/api/utilisateurs/moi") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); } @Test @DisplayName("Un endpoint protégé retourne 401 sans JWT") void endpoint_protege_refuse_sans_jwt() throws Exception { mockMvc.perform(get("/api/utilisateurs/moi")) .andExpect(status().isUnauthorized()); } private String obtenirTokenValide() throws Exception { LoginDTO loginDTO = new LoginDTO("user@formation.fr", "User1234!"); MvcResult result = mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(loginDTO))) .andReturn(); return objectMapper.readTree(result.getResponse().getContentAsString()) .get("token").asText(); } }
Objectif : Configurer Spring Security avec des utilisateurs en mémoire.
Énoncé :
À partir du projet fourni (sans sécurité), ajoutez Spring Security et configurez :
/
/profil
alice
admin
/login?logout
SOLUTION EXERCICE 1 :
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/").permitAll() .requestMatchers("/css/**").permitAll() .requestMatchers("/login").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/profil/**").authenticated() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .defaultSuccessUrl("/", true) .failureUrl("/login?error") .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") .permitAll() ); return http.build(); } @Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { UserDetails alice = User.builder() .username("alice") .password(encoder.encode("alice123")) .roles("USER") .build(); UserDetails admin = User.builder() .username("admin") .password(encoder.encode("admin123")) .roles("USER", "ADMIN") .build(); return new InMemoryUserDetailsManager(alice, admin); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
Tests à effectuer :
1. http://localhost:8080/ → Accessible sans login 2. http://localhost:8080/admin → Redirige vers /login 3. Se connecter avec alice/alice123 → Accès à /profil, ❌ Refus pour /admin 4. Se connecter avec admin/admin123 → Accès à /admin 5. Déconnexion → Redirige vers /login?logout
Objectif : Remplacer l’authentification en mémoire par une base de données.
Utilisateur
CustomUserDetailsService
/inscription
SOLUTION EXERCICE 2 :
// CustomUserDetailsService — solution complète @Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final UtilisateurRepository repository; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Utilisateur utilisateur = repository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("Email introuvable : " + email)); return User.builder() .username(utilisateur.getEmail()) .password(utilisateur.getMotDePasse()) .authorities(utilisateur.getRole().name()) .accountLocked(!utilisateur.isActif()) .build(); } }
// AuthController — solution inscription @PostMapping("/inscription") public String traiterInscription( @Valid @ModelAttribute InscriptionDTO dto, BindingResult result, HttpServletRequest request) { if (!dto.motDePasseCorrespond()) { result.rejectValue("confirmationMotDePasse", "", "Les mots de passe ne correspondent pas"); } if (result.hasErrors()) return "inscription"; try { utilisateurService.inscrire(dto); // Connexion automatique après inscription UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(dto.getEmail(), dto.getMotDePasse()); Authentication auth = authenticationManager.authenticate(token); SecurityContextHolder.getContext().setAuthentication(auth); return "redirect:/"; } catch (EmailDejaUtiliseException e) { result.rejectValue("email", "", e.getMessage()); return "inscription"; } }
Objectif : Utiliser les annotations de sécurité sur les méthodes.
UtilisateurService.findAll()
UtilisateurService.findByEmail()
GET /admin/utilisateurs
** SOLUTION EXERCICE 3 :**
// SecurityConfig — activer les annotations méthodes @Configuration @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { /* ... */ }
// UtilisateurService — sécurisation des méthodes @Service public class UtilisateurService { @PreAuthorize("hasRole('ADMIN')") public List<Utilisateur> findAll() { return repository.findAll(); } @PreAuthorize("hasRole('ADMIN') or #email == authentication.name") public Utilisateur findByEmail(String email) { return repository.findByEmail(email) .orElseThrow(() -> new RuntimeException("Introuvable")); } @PreAuthorize("hasRole('ADMIN')") public void desactiver(Long id) { Utilisateur u = repository.findById(id).orElseThrow(); u.setActif(false); repository.save(u); } }
// Test — vérifier que les autorisations fonctionnent @Test @WithMockUser(roles = "USER") void findAll_refuse_pour_user() { assertThrows(AccessDeniedException.class, () -> utilisateurService.findAll()); } @Test @WithMockUser(username = "alice@test.com", roles = "USER") void findByEmail_autorise_pour_proprietaire() { // Alice peut accéder à son propre profil assertDoesNotThrow(() -> utilisateurService.findByEmail("alice@test.com")); } @Test @WithMockUser(username = "alice@test.com", roles = "USER") void findByEmail_refuse_pour_autre_utilisateur() { // Alice NE PEUT PAS accéder au profil de Bob assertThrows(AccessDeniedException.class, () -> utilisateurService.findByEmail("bob@test.com")); }
Objectif : Créer une API REST sécurisée avec JWT.
JwtService
JwtAuthenticationFilter
POST /api/auth/login
GET /api/utilisateurs/moi
GET /api/admin/stats
** SOLUTION EXERCICE 4 :**
// Controller API — endpoint "moi" @RestController @RequestMapping("/api") @RequiredArgsConstructor public class ApiUtilisateurController { private final UtilisateurService utilisateurService; @GetMapping("/utilisateurs/moi") public ResponseEntity<?> monProfil(Authentication authentication) { Utilisateur utilisateur = utilisateurService .findByEmail(authentication.getName()); return ResponseEntity.ok(Map.of( "email", utilisateur.getEmail(), "nom", utilisateur.getNomComplet(), "role", utilisateur.getRole().getLibelle() )); } @GetMapping("/admin/stats") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<?> stats() { return ResponseEntity.ok(Map.of( "totalUtilisateurs", utilisateurService.findAll().size() )); } }
# Tests avec curl # 1. Login → obtenir le JWT POST http://localhost:8080/api/auth/login \ { "email":"user@formation.fr", "motDePasse":"User1234!" } # Réponse : # {"token":"eyJ...","type":"Bearer","email":"user@formation.fr"} # 2. Utiliser le JWT pour accéder à un endpoint protégé JWT="eyJ..." "Authorization: Bearer $JWT" http://localhost:8080/api/utilisateurs/moi # 3. Accès refusé sans JWT http://localhost:8080/api/utilisateurs/moi # → {"erreur":"Non authentifié","message":"Token JWT requis"} # 4. Accès refusé avec JWT user pour endpoint admin "Authorization: Bearer $JWT" http://localhost:8080/api/admin/stats # → {"erreur":"Accès refusé","message":"Droits insuffisants"}
Vous disposez de 2 projets ZIP :
spring-security-starter.zip
Une bibliothèque en ligne avec 3 niveaux d’accès :
Étape 1 — Ajouter Spring Security
pom.xml
SecurityConfig
Étape 2 — Authentification BDD
Role
Étape 3 — Autorisation fine
@PreAuthorize
Étape 4 — Interface Thymeleaf sécurisée
thymeleaf-extras-springsecurity6
sec:authorize
Étape 5 — API JWT
Étape 6 — Tests
@WithMockUser
admin@formation.fr
Admin1234!
user@formation.fr
User1234!
charlie@formation.fr
Charlie1234!
/admin/**
/user/**
AuthenticationProvider
authorizeHttpRequests()
BCryptPasswordEncoder
SessionCreationPolicy
OncePerRequestFilter
// JAMAIS — mot de passe en clair user.setPassword("monmotdepasse"); // JAMAIS — NoOpPasswordEncoder en production new NoOpPasswordEncoder(); // JAMAIS — désactiver la sécurité globalement http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); // JAMAIS — ignorer CSRF sur les formulaires web http.csrf(csrf -> csrf.disable()); // Seulement pour les APIs stateless JWT // JAMAIS — clé JWT courte ou prévisible @Value("${jwt.secret:secret}"); // "secret" est trop court et prévisible ! // JAMAIS — secrets dans le code source private final String JWT_SECRET = "ma-super-cle-secrete"; // Commit = compromis ! // TOUJOURS — dans application.properties ou variables d'environnement @Value("${app.jwt.secret}") // Depuis la config externe private String jwtSecret;
C’est déjà pas mal…