Aller au contenu

Formation : Introduction à Spring Security


Table des matières

  1. Introduction — Pourquoi Spring Security ?
  2. Les concepts fondamentaux de la sécurité web
  3. Mise en place du projet
  4. Premier contact - la configuration de base
  5. Authentification avec base de données
  6. Autorisation — Contrôler les accès
  7. Protection CSRF et headers de sécurité
  8. Thymeleaf et Spring Security
  9. Authentification JWT pour les APIs REST
  10. Tests de sécurité
  11. Exercices pratiques avec solutions
  12. TP Application complète sécurisée

1. Introduction

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

1.1 Pourquoi sécuriser son application ?

Imaginez que vous ouvrez un magasin. Sans sécurité :

Le web, c’est pareil. Une application web non sécurisée est exposée à :

Attaque Description Exemple
Accès non autorisé Un utilisateur accède à des pages réservées Un visiteur accède à /admin
Vol d’identité Usurpation de compte Connexion avec le compte d’un autre
Injection SQL Manipulation de la base de données ' OR '1'='1 dans un formulaire
XSS Injection de scripts malveillants Vol de cookies de session
CSRF Forcer des actions à l’insu de l’utilisateur Virement bancaire sans consentement
Brute Force Essayer toutes les combinaisons de mots de passe 10 000 tentatives de connexion/minute

1.2 Qu’est-ce que Spring Security ?

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

1.3 Spring Security 6 vs les versions précédentes

Spring Security 6 (avec Spring Boot 3.x) apporte des changements majeurs par rapport aux versions antérieures :

Ancienne façon (Spring Boot 2.x) Nouvelle façon (Spring Boot 3.x)
extends WebSecurityConfigurerAdapter Plus de classe à étendre — configuration par beans
http.authorizeRequests() http.authorizeHttpRequests()
antMatchers("/url") requestMatchers("/url")
WebSecurityConfigurerAdapter SecurityFilterChain bean

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 !

1.4 Comment Spring Security fonctionne

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.


2. Les concepts fondamentaux

2.1 Authentification vs Autorisation

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).

2.2 Le SecurityContext

Spring Security stocke les informations de l’utilisateur connecté dans un objet appelé SecurityContext, accessible partout dans l’application.

// 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.

2.3 UserDetails et UserDetailsService

Spring Security utilise deux interfaces clés pour l’authentification :

UserDetails : Représente un utilisateur avec ses informations de sécurité

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

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é.

2.4 Les rôles et les authorities

Les rôles sont des autorisations nommées préfixées de ROLE_ :

Les authorities sont des permissions plus granulaires :

// 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

2.5 Le hachage des mots de passe

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$..."

3. Mise en place du projet

3.1 Dépendances Maven

<!-- 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>

3.2 Structure du projet

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

3.3 Configuration 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

4. Premier contact : la configuration de base

4.1 Ce qui se passe sans configuration

Dès que vous ajoutez la dépendance spring-boot-starter-security, Spring Boot applique une configuration par défaut automatique :

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.

4.2 La configuration SecurityFilterChain

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

4.3 Un premier utilisateur en mémoire (pour les tests)

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.

4.4 Page de login personnalisée

<!-- 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>

4.5 Le Controller — Ce qu’il faut (et ne faut pas) faire

// 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 !
}

5. Authentification avec base de données

5.1 L’entité Utilisateur

// 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_", "");
    }
}

5.2 Le Repository

// 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);
}

5.3 CustomUserDetailsService — Le pont Spring Security ↔ BDD

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.

5.4 Le Service Utilisateur

// 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);
    }
}

5.5 Mettre à jour SecurityConfig pour utiliser la BDD

// 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();
    }
}

5.6 Le DTO d’inscription et le Controller

// 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";
        }
    }
}

5.7 Initialisation des données de démo

// 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!");
        };
    }
}

6. Autorisation — Contrôler les accès

6.1 Autorisation au niveau des URLs (dans SecurityConfig)

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()
);

6.2 Autorisation au niveau des méthodes — @PreAuthorize

@EnableMethodSecurity permet d’utiliser des annotations directement sur les méthodes des services et controllers. C’est plus fin et plus flexible.

// 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) {
        // ...
    }
}

6.3 Autorisation dans les Controllers

// 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";
    }
}

6.4 Gestion des accès refusés

// 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>

7. Protection CSRF

7.1 Qu’est-ce que le CSRF ?

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 !

7.2 La protection CSRF de Spring Security

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)
});

7.3 Désactiver le CSRF (APIs REST stateless)

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
    );

7.4 Les headers de sécurité HTTP

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")
    )
);

8. Thymeleaf et Spring Security

8.1 Le namespace Spring Security dans Thymeleaf

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">

8.2 Affichage conditionnel selon l’authentification

<!-- 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>

8.3 Afficher les informations de l’utilisateur connecté

<!-- 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>

8.4 Exemple complet : barre de navigation sécurisée

<!-- 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>

9. Authentification JWT pour les APIs REST

9.1 Pourquoi JWT ?

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

9.2 Structure d’un JWT

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)

** 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.

9.3 Le service JWT

// 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);
    }
}

9.4 Le filtre JWT

// 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);
    }
}

9.5 Configuration Security pour les APIs JWT

// 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();
}

9.6 Le Controller d’authentification API

// 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é"
            ));
        }
    }
}

10. Tests de sécurité

10.1 Tester avec @WithMockUser

// 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
    }
}

10.2 Tester les services avec @WithUserDetails

@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();
        });
    }
}

10.3 Tester les endpoints JWT

@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();
    }
}

11. Exercices pratiques

EXERCICE 1 — Configuration de base

Objectif : Configurer Spring Security avec des utilisateurs en mémoire.

Énoncé :

À partir du projet fourni (sans sécurité), ajoutez Spring Security et configurez :

  1. La page / accessible à tous
  2. La page /admin accessible uniquement aux admins
  3. La page /profil accessible uniquement aux utilisateurs connectés
  4. Deux utilisateurs en mémoire : alice (ROLE_USER) et admin (ROLE_ADMIN)
  5. Une page de login personnalisée
  6. La déconnexion redirige vers /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

EXERCICE 2 — Authentification avec BDD

Objectif : Remplacer l’authentification en mémoire par une base de données.

Énoncé :

  1. Créez l’entité Utilisateur avec les champs : id, email, motDePasse, role, actif
  2. Créez CustomUserDetailsService qui charge depuis la BDD
  3. Créez un formulaire d’inscription (/inscription)
  4. Validez que les mots de passe correspondent à l’inscription
  5. Après inscription, connectez automatiquement l’utilisateur
  6. Initialisez un admin et un utilisateur en base au démarrage

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";
    }
}

EXERCICE 3 — @PreAuthorize et contrôle fin

Objectif : Utiliser les annotations de sécurité sur les méthodes.

Énoncé :

  1. Activez @EnableMethodSecurity
  2. Protégez UtilisateurService.findAll() : admin uniquement
  3. Protégez UtilisateurService.findByEmail() : admin OU propriétaire du compte
  4. Créez un endpoint admin GET /admin/utilisateurs qui liste tous les comptes
  5. Vérifiez que les accès non autorisés retournent 403

** 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"));
}

EXERCICE 4 — API REST avec JWT

Objectif : Créer une API REST sécurisée avec JWT.

Énoncé :

  1. Créez JwtService pour générer et valider les tokens
  2. Créez JwtAuthenticationFilter
  3. Créez POST /api/auth/login qui retourne un JWT
  4. Protégez GET /api/utilisateurs/moi (utilisateur connecté uniquement)
  5. Protégez GET /api/admin/stats (admin uniquement)
  6. Testez avec curl ou Postman

** 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"}

12. TP Application complète sécurisée

12.1 Objectif

Vous disposez de 2 projets ZIP :

12.2 L’application — “SecureLib”

Une bibliothèque en ligne avec 3 niveaux d’accès :

12.3 Étapes progressives

Étape 1 — Ajouter Spring Security

Étape 2 — Authentification BDD

Étape 3 — Autorisation fine

Étape 4 — Interface Thymeleaf sécurisée

Étape 5 — API JWT

Étape 6 — Tests

12.4 Identifiants de démo (dans les deux projets)

Email Mot de passe Rôle
admin@formation.fr Admin1234! ADMIN
user@formation.fr User1234! USER
charlie@formation.fr Charlie1234! USER

12.5 Critères


Récapitulatif des concepts clés

Concept Explication Élément Spring Security
Authentification Vérifier l’identité UserDetailsService, AuthenticationProvider
Autorisation Vérifier les droits authorizeHttpRequests(), @PreAuthorize
Mot de passe Hashage sécurisé BCryptPasswordEncoder
Session État de connexion SecurityContext, SessionCreationPolicy
CSRF Protection contre attaques inter-sites Token CSRF automatique (Thymeleaf)
JWT Auth stateless pour APIs JwtService, JwtAuthenticationFilter
Filtre Traitement des requêtes SecurityFilterChain, OncePerRequestFilter
Rôle Niveau d’accès ROLE_USER, ROLE_ADMIN

Les erreurs à ne JAMAIS faire

//  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…