Tutoriel Complet sur Spring Security : Créez une API REST sécurisée pour une To-Do List

Introduction
Spring Security est le framework de sécurité de Spring, conçu pour sécuriser les applications Spring Boot. Ce tutoriel détaillé vous permettra de :
- Comprendre les concepts fondamentaux de Spring Security.
- Créer une API REST sécurisée pour une to-do list.
- Utiliser PostgreSQL comme base de données.
Pré-requis :
- Connaissances de base en Java et Spring Boot.
- PostgreSQL installé.
- Outils recommandés : IntelliJ IDEA ou VS Code.
Voyons comment sécuriser votre application Spring Boot avec Spring Security ! 😊
1. Concepts Fondamentaux de Spring Security (Explication Détaillée)
Spring Security est une solution puissante et flexible pour sécuriser les applications Java/Spring. Avant de plonger dans le projet pratique, il est essentiel de comprendre les principaux composants et concepts suivants :
1.1 SecurityContext
Le SecurityContext
est un conteneur central utilisé par Spring Security pour stocker les informations de sécurité de la session en cours.
-
Contenu principal :
LeSecurityContext
contient un objetAuthentication
qui représente l'utilisateur authentifié ou en cours d'authentification. -
Pourquoi est-il important ?
Il permet de maintenir les informations sur l'utilisateur courant tout au long du traitement de la requête. -
Exemple d'accès :
Vous pouvez accéder auSecurityContext
en appelant :SecurityContextHolder.getContext().getAuthentication();
-
Cas pratique : Lorsque vous avez besoin de récupérer l'utilisateur actuellement connecté (par exemple, pour charger ses tâches dans une to-do list), vous pouvez utiliser le
SecurityContext
.
1.2 AuthenticationManager
Le AuthenticationManager
est le cœur du processus d'authentification dans Spring Security.
-
Rôle :
Il traite les demandes d'authentification en vérifiant les informations d'identification de l'utilisateur (nom d'utilisateur, mot de passe, etc.) par rapport à une source de données (comme une base de données ou un système LDAP). -
Fonctionnement :
- Lorsqu'un utilisateur soumet ses informations d'authentification, elles sont encapsulées dans un objet
Authentication
. - Le
AuthenticationManager
tente ensuite de les authentifier à l'aide des stratégies définies.
- Lorsqu'un utilisateur soumet ses informations d'authentification, elles sont encapsulées dans un objet
-
Personnalisation :
Vous pouvez personnaliser l'AuthenticationManager
pour intégrer vos propres stratégies d'authentification. -
Exemple de configuration :
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("admin") .password(passwordEncoder().encode("password")) .roles("ADMIN"); }
1.3 UserDetails et UserDetailsService
-
UserDetails
:
C'est une interface qui représente un utilisateur dans Spring Security. Elle définit les informations nécessaires pour l'authentification et l'autorisation, telles que :- Nom d'utilisateur.
- Mot de passe (souvent encodé).
- Rôles ou permissions (via
GrantedAuthority
).
-
UserDetailsService
:
Une interface utilisée pour charger les informations utilisateur depuis une source de données (comme une base de données). -
Rôle dans l'application :
LeUserDetailsService
est appelé par Spring Security pour rechercher et vérifier les informations utilisateur lors de l'authentification. -
Exemple de personnalisation :
@Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), Collections.singletonList(new SimpleGrantedAuthority(user.getRole())) ); } }
1.4 GrantedAuthority
-
Rôle :
UneGrantedAuthority
représente une permission ou un rôle attribué à un utilisateur (par exemple,ROLE_USER
,ROLE_ADMIN
).
Ces permissions déterminent ce qu'un utilisateur peut faire dans l'application. -
Exemple d'utilisation :
Lors de la création d'un utilisateur, vous pouvez attribuer un rôle :new SimpleGrantedAuthority("ROLE_USER");
1.5 PasswordEncoder
Spring Security n'enregistre jamais de mots de passe en texte clair. Au lieu de cela, il utilise un PasswordEncoder
pour :
- Encoder les mots de passe avant de les stocker.
- Vérifier les mots de passe lors de l'authentification.
-
Exemple avec BCryptPasswordEncoder :
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
-
Pourquoi est-ce important ?
Le chiffrement garantit que même si la base de données est compromise, les mots de passe restent sécurisés.
1.6 Filtre de Sécurité (Filter Chain)
Spring Security utilise une chaîne de filtres (filter chain) pour gérer les requêtes HTTP.
-
Fonctionnement :
- Chaque requête passe par une série de filtres.
- Ces filtres effectuent des tâches spécifiques comme vérifier les jetons JWT, authentifier les utilisateurs, ou appliquer des règles de sécurité.
-
Exemple de filtres courants :
UsernamePasswordAuthenticationFilter
: Authentifie les utilisateurs via un formulaire de connexion.JwtAuthenticationFilter
: Vérifie la validité d'un token JWT.
1.7. Étapes Préparatoires : Pourquoi Ces Concepts Sont-ils Essentiels ?
Ces concepts sont cruciaux pour comprendre comment sécuriser une application Spring Boot. Ils serviront de fondation pour notre projet pratique, où nous implémenterons :
- Un système d'authentification utilisant des noms d'utilisateur et mots de passe.
- Un contrôle d'accès basé sur les rôles pour protéger les différentes API.
- Un chiffrement des mots de passe avec
PasswordEncoder
. - Une sécurité basée sur JWT pour des interactions sécurisées entre le client et le serveur.
2. Configuration de l'Environnement
Nous allons construire une API REST Todo List sécurisée avec Spring Security et Postgres, en suivant les étapes détaillées. Chaque section inclura les explications, la configuration et le code pour chaque fichier.
Plan du Projet
- Initialisation du Projet Spring Boot.
- Configuration de Spring Security.
- Mise en place du modèle de données avec PostgreSQL.
- Authentification et Autorisation (JWT).
- Création des Endpoints API REST (CRUD Todo List).
- Test et sécurisation des routes.
1. Initialisation du Projet Spring Boot
1.1 Créez le projet avec Spring Initializr
Allez sur Spring Initializr et configurez votre projet comme suit :
- Nom :
todo-list-api
. - Dependencies :
- Spring Web : Pour créer les API REST.
- Spring Security : Pour gérer la sécurité.
- Spring Data JPA : Pour interagir avec PostgreSQL.
- PostgreSQL Driver : Pour connecter la base de données.
- Lombok : Pour réduire le code boilerplate.
** Organisez votre projet comme suit : **
src/main/java/com/example/todolist/
├── controller
├── model
├── repository
├── service
├── security
└── config
Téléchargez le projet, extrayez-le, et ouvrez-le dans votre IDE préféré.
1.2 Configuration du fichier application.properties
Modifiez le fichier src/main/resources/application.properties
pour connecter PostgreSQL.
# Configurations de PostgreSQL spring.datasource.url=jdbc:postgresql://localhost:5432/todo_db spring.datasource.username=postgres spring.datasource.password=mot_de_passe # Configuration JPA spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect # Port du serveur server.port=8080
2. Configuration de Spring Security
Spring Security est au cœur de la sécurisation de notre API. Voici comment le configurer étape par étape. pour commencer nous allons creer un model User
**2.0 Création du modèle User
et du Repository **
Créez une classe User
pour stocker les informations des utilisateurs.
Fichier : User.java
package com.example.todo.model; import jakarta.persistence.*; import lombok.Data; import java.util.Set; @Entity @Data public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String password; @ElementCollection(fetch = FetchType.EAGER) private Set<String> roles; }
Fichier : UserRepository.java
package com.example.todo.repository; import com.example.todo.model.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByUsername(String username); }
2.1 Création d'un fichier de configuration
Ajoutez une classe pour configurer Spring Security.
Fichier : SecurityConfig.java
package com.example.todo.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ) .httpBasic(); return http.build(); } }
- Explications :
csrf().disable()
: Désactive la protection CSRF (utile pour les API REST).requestMatchers("/api/auth/**").permitAll()
: Permet un accès public aux endpoints d'authentification.anyRequest().authenticated()
: Toutes les autres routes nécessitent une authentification.
2.2 Service Utilisateur Personnalisé
Créez une implémentation de UserDetailsService
pour charger les utilisateurs depuis la base de données.
Fichier : CustomUserDetailsService.java
package com.example.todo.security; import com.example.todo.model.User; import com.example.todo.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getRoles() ); } }
3. Authentification et Autorisation avec JWT
L'authentification basée sur JWT (JSON Web Token) permet de sécuriser les API REST de manière stateless. Un utilisateur authentifié reçoit un token qu'il envoie dans chaque requête pour prouver son identité.
3.1 Ajout des Dépendances JWT
Ajoutez la dépendance suivante dans le fichier pom.xml
:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.6</version> </dependency>
Mettez à jour les dépendances avec Maven.
3.2 Création d'un Service JWT
Ajoutez une classe pour générer et valider les tokens JWT.
Fichier : JwtService.java
package com.example.todo.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.stereotype.Service; import java.util.Date; import java.util.function.Function; @Service public class JwtService { private static final String SECRET_KEY = "secret-key"; public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } private Claims extractAllClaims(String token) { return Jwts.parserBuilder() .setSigningKey(SECRET_KEY.getBytes()) .build() .parseClaimsJws(token) .getBody(); } public String generateToken(String username) { return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // 10 heures .signWith(SignatureAlgorithm.HS256, SECRET_KEY.getBytes()) .compact(); } public boolean isTokenValid(String token, String username) { final String extractedUsername = extractUsername(token); return extractedUsername.equals(username) && !isTokenExpired(token); } private boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } private Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } }
3.3 Configuration de JWT dans Spring Security
Ajoutez un filtre pour valider les tokens JWT dans chaque requête.
Fichier : JwtAuthenticationFilter.java
package com.example.todo.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtService jwtService; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { final String authHeader = request.getHeader("Authorization"); final String jwt; final String username; if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } jwt = authHeader.substring(7); username = jwtService.extractUsername(jwt); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); if (jwtService.isTokenValid(jwt, userDetails.getUsername())) { var authToken = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } filterChain.doFilter(request, response); } }
3.4 Mise à jour de la configuration Spring Security
Ajoutez le filtre JWT dans la classe SecurityConfig
.
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }
La partie JWT est prête! Vous pouvez maintenant tester les routes d'inscription (/api/auth/register
) et de connexion (/api/auth/login
) pour obtenir un token.
5. Création des Endpoints pour la Gestion des Tâches
Maintenant que l'authentification est en place, nous allons créer les endpoints REST pour gérer les tâches (TODOs). Ces endpoints seront sécurisés : seuls les utilisateurs authentifiés pourront y accéder.
5.1 Création de l'Entité Task
Définissons une entité JPA pour représenter les tâches.
Fichier : Task.java
package com.example.todo.model; import jakarta.persistence.*; import lombok.Data; import java.time.LocalDateTime; @Data @Entity @Table(name = "tasks") public class Task { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String description; private boolean completed; private LocalDateTime createdAt; private LocalDateTime updatedAt; @ManyToOne @JoinColumn(name = "user_id", nullable = false) private User user; }
5.2 Création du Repository pour Task
Ajoutons un repository pour effectuer les opérations CRUD sur les tâches.
Fichier : TaskRepository.java
package com.example.todo.repository; import com.example.todo.model.Task; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface TaskRepository extends JpaRepository<Task, Long> { List<Task> findByUserId(Long userId); }
5.3 Création du Service pour Task
Ajoutons une couche de service pour encapsuler la logique métier des tâches.
Fichier : TaskService.java
package com.example.todo.service; import com.example.todo.model.Task; import com.example.todo.model.User; import com.example.todo.repository.TaskRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.List; @Service public class TaskService { @Autowired private TaskRepository taskRepository; public List<Task> getAllTasksForUser(User user) { return taskRepository.findByUserId(user.getId()); } public Task createTask(Task task, User user) { task.setUser(user); task.setCreatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); return taskRepository.save(task); } public Task updateTask(Long taskId, Task updatedTask, User user) { Task task = taskRepository.findById(taskId) .orElseThrow(() -> new RuntimeException("Task not found")); if (!task.getUser().getId().equals(user.getId())) { throw new RuntimeException("Unauthorized"); } task.setTitle(updatedTask.getTitle()); task.setDescription(updatedTask.getDescription()); task.setCompleted(updatedTask.isCompleted()); task.setUpdatedAt(LocalDateTime.now()); return taskRepository.save(task); } public void deleteTask(Long taskId, User user) { Task task = taskRepository.findById(taskId) .orElseThrow(() -> new RuntimeException("Task not found")); if (!task.getUser().getId().equals(user.getId())) { throw new RuntimeException("Unauthorized"); } taskRepository.delete(task); } }
5.4 Création du Contrôleur pour Task
Ajoutons un contrôleur pour exposer les endpoints REST pour la gestion des tâches.
Fichier : TaskController.java
package com.example.todo.controller; import com.example.todo.model.Task; import com.example.todo.model.User; import com.example.todo.service.TaskService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/tasks") public class TaskController { @Autowired private TaskService taskService; @GetMapping public List<Task> getAllTasks(@AuthenticationPrincipal User user) { return taskService.getAllTasksForUser(user); } @PostMapping public Task createTask(@RequestBody Task task, @AuthenticationPrincipal User user) { return taskService.createTask(task, user); } @PutMapping("/{taskId}") public Task updateTask(@PathVariable Long taskId, @RequestBody Task task, @AuthenticationPrincipal User user) { return taskService.updateTask(taskId, task, user); } @DeleteMapping("/{taskId}") public String deleteTask(@PathVariable Long taskId, @AuthenticationPrincipal User user) { taskService.deleteTask(taskId, user); return "Task deleted successfully!"; } }
5.5 Résultat Final
Voici les endpoints disponibles pour gérer les tâches :
- GET
/api/tasks
: Récupère toutes les tâches de l'utilisateur connecté. - POST
/api/tasks
: Crée une nouvelle tâche. - PUT
/api/tasks/{taskId}
: Met à jour une tâche existante. - DELETE
/api/tasks/{taskId}
: Supprime une tâche.
6. Tester l'API
Vous pouvez tester ces endpoints avec Postman ou cURL. Lors de chaque requête, ajoutez un header Authorization
avec le token JWT récupéré lors de la connexion.
Exemple d'en-tête :
Authorization: Bearer <votre_token>
7. Documentation des APIs avec Swagger
Spring Boot facilite l'intégration de Swagger pour documenter vos endpoints REST. Swagger permet non seulement de générer une documentation interactive mais également de tester les endpoints directement via une interface web.
7.1 Ajouter les Dépendances Swagger
Ajoutez les dépendances nécessaires dans votre fichier pom.xml
.
Fichier : pom.xml
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.2.0</version> <!-- Vérifiez la dernière version disponible --> </dependency>
7.2 Configurer Swagger
Dans la plupart des cas, aucune configuration supplémentaire n’est nécessaire avec SpringDoc. Cependant, vous pouvez personnaliser le comportement en créant une classe de configuration.
Fichier : OpenApiConfig.java
package com.example.todo.config; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class OpenApiConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("TODO List API") .version("1.0") .description("Documentation des APIs pour la gestion des tâches avec Spring Security")); } }
7.3 Accéder à l’Interface Swagger
Démarrez votre application Spring Boot et rendez-vous à l’adresse suivante dans votre navigateur :
http://localhost:8080/swagger-ui.html
Vous verrez une interface interactive contenant tous les endpoints documentés. Chaque endpoint sera accompagné des méthodes HTTP, des paramètres nécessaires, des réponses possibles, etc.
8. Tester les Endpoints avec Swagger
Swagger vous permet de tester directement les endpoints. Voici un exemple pour utiliser l'interface Swagger :
-
Authentifiez-vous :
- Utilisez le endpoint
/api/auth/login
pour récupérer un token JWT. - Copiez le token reçu dans la réponse.
- Utilisez le endpoint
-
Ajouter le Token JWT :
- Cliquez sur le bouton "Authorize" dans Swagger.
- Entrez le token JWT dans le champ sous la forme :
Bearer <votre_token>
-
Tester les Endpoints :
- Testez les endpoints comme
GET /api/tasks
,POST /api/tasks
,PUT /api/tasks/{taskId}
, etc. - Fournissez les données nécessaires via l'interface interactive.
- Testez les endpoints comme
9. Résultat Final dans Swagger
Voici un aperçu des endpoints visibles dans Swagger après configuration :
-
Endpoints d’Authentification :
POST /api/auth/register
: Enregistrer un nouvel utilisateur.POST /api/auth/login
: Se connecter et obtenir un token JWT.
-
Endpoints pour la Gestion des Tâches :
GET /api/tasks
: Récupérer toutes les tâches de l’utilisateur connecté.POST /api/tasks
: Créer une nouvelle tâche.PUT /api/tasks/{taskId}
: Mettre à jour une tâche existante.DELETE /api/tasks/{taskId}
: Supprimer une tâche.
10. Étapes Suivantes
- Sécuriser davantage l'application : Ajoutez des rôles (comme
ADMIN
,USER
) pour différencier les autorisations. - Améliorer les validations : Utilisez des annotations comme
@Valid
pour valider les requêtes entrantes. - Déployer l'application : Déployez votre application sur un serveur comme Heroku ou AWS.