Le processus d'authentification et d'autorisation dans Spring Security implique plusieurs étapes clés pour sécuriser les applications web. Il garantit que seuls les utilisateurs authentifiés et autorisés peuvent accéder aux ressources protégées.
Flux d'Authentification et d'Autorisation
- Un utilisateur soumet ses identifiants (généralement nom d'utilisateur et mot de passe) pour se connecter.
- La requête de connexion traverse une série de filtres Spring Security. Les filtres appropriés permettent à la requête de cnotinuer vers le processus d'authentification.
- Spring Security encapsule les identifiants fournis dans un objet
UsernamePasswordAuthenticationToken, qui implémente l'interfaceAuthentication. - L'objet
AuthenticationManagerest chargé d'authentifier ce jeton. L'appelauthenticationManager.authenticate(authenticationToken)déclenche la vérification des identifiants. - L'
AuthenticationManagereffectue l'authentification :- En cas de succès, il renvoie un objet
Authenticationcontenant les détails de l'utilisateur, y compris ses rôles et permissions. - En cas d'échec, une exception
AuthenticationExceptionest levée. Cette exception est interceptée et gérée par unAuthenticationEntryPoint, qui peut guider l'utilisateur vers une nouvelle tentative de connexion.
- En cas de succès, il renvoie un objet
- L'objet
Authenticationrenvoyé par le gestionnaire est stocké dans le contexte de sécurité actuel à l'aide deSecurityContextHolder.getContext().setAuthentication(...). - Lors de l'accès à une ressource protégée après une authentification réussie, l'objet
Authenticationstocké est utilisé pour vérifier les permissions de l'utilisateur. Si l'accès est refusé, une exceptionAccessDeniedExceptionest levée, traitée par leExceptionTranslationFilter, et une réponse avec le code d'état 403 (Forbidden) est renvoyée au client.
Exemple de Configuration
Voici un exemple de configuration Spring Security qui utilise une authentification basée sur des données stockées en base de données, avec gestion des erreurs personnalisées pour les rejets d'authentification (401) et d'autorisation (403).
Configuration de Sécurité
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
public SecurityConfig(AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler,
JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter) {
this.authenticationEntryPoint = authenticationEntryPoint;
this.accessDeniedHandler = accessDeniedHandler;
this.jwtAuthenticationTokenFilter = jwtAuthenticationTokenFilter;
}
// Bean pour l'encodage des mots de passe
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() // Désactiver CSRF
.cors() // Gérer le CORS
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // Désactiver les sessions pour les architectures sans état (front-end séparé)
.and()
.authorizeRequests()
.antMatchers("/auth/login").permitAll() // Autoriser l'accès à l'endpoint de connexion
.anyRequest().authenticated(); // Exiger l'authentification pour toutes les autres requêtes
// Ajouter le filtre de validation du token avant le filtre d'authentification par nom d'utilisateur/mot de passe
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// Configurer les gestionnaires d'exceptions pour les erreurs d'authentification et d'autorisation
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint) // Gérer les erreurs 401 (non authentifié)
.accessDeniedHandler(accessDeniedHandler); // Gérer les erreurs 403 (non autorisé)
}
// Bean pour le AuthenticationManager
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
Pour les applications où le front-end est séparé du back-end, il est courant de désactiver la gestion des sessions (SessionCreationPolicy.STATELESS) et de gérer l'authentification via des tokens (comme JWT). Le filtre JwtAuthenticationTokenFilter est ajouté pour valider le token avant que la requête n'atteigne d'autres filtres. Des gestionnaires personnalisés pour AuthenticationEntryPoint (pour les rejets 401) et AccessDeniedHandler (pour les rejets 403) sont définis pour renvoyer des réponses appropriées au client.
Gestionnaires d'Exceptions Personnalisés
@Configuration
public class SecurityHandlers {
// Gestionnaire pour les échecs d'authentification (401)
@Bean
public AuthenticationEntryPoint customAuthenticationEntryPoint() {
return (request, response, authException) -> {
// Utilisation d'un utilitaire pour rendre une réponse JSON standardisée
WebUtils.renderString(response, JsonUtil.toJsonString(Result.error("401 - Échec de l'authentification, veuillez vous connecter.")), 401);
};
}
// Gestionnaire pour les échecs d'autorisation (403)
@Bean
public AccessDeniedHandler customAccessDeniedHandler() {
return (request, response, accessDeniedException) -> {
WebUtils.renderString(response, JsonUtil.toJsonString(Result.error("403 - Échec de l'autorisation, permission refusée.")), 403);
};
}
}
Utilitaires de Réponse
L'utilitaire WebUtils simplifie l'envoi de réponses JSON formatées au client.
public class WebUtils {
/**
* Rend une chaîne de caractères au client en l'écrivant dans la réponse HTTP.
* @param response L'objet HttpServletResponse.
* @param data La chaîne de caractères à écrire.
* @param statusCode Le code de statut HTTP à définir.
*/
public static void renderString(HttpServletResponse response, String data, Integer statusCode) {
try {
response.setStatus(statusCode);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(data);
} catch (IOException e) {
// Log de l'exception ou gestion appropriée
e.printStackTrace();
}
}
}
Implémentation de UserDetails
La classe LoginUser implémente UserDetails pour fournir les informations nécessaires à Spring Security concernant l'utilisateur authentifié, y compris ses rôles et permissions.
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user; // Représente l'utilisateur de l'application, potentiellement avec des détails comme le rôle et les permissions.
public LoginUser(User user) {
this.user = user;
}
// Liste des permissions de l'utilisateur
private List<simplegrantedauthority> authorities;
@Override
public Collection extends GrantedAuthority> getAuthorities() {
if (this.authorities != null && !this.authorities.isEmpty()) {
return this.authorities;
}
this.authorities = new ArrayList<>();
Role role = user.getRole(); // Supposant que User a une relation avec Role
// Ajout du rôle avec le préfixe "ROLE_" obligatoire pour Spring Security
if (role != null) {
this.authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
// Ajout des permissions associées au rôle
Set<permission> permissions = role.getPermissions(); // Supposant que Role a une collection de Permissions
if (permissions != null) {
permissions.forEach(permission ->
this.authorities.add(new SimpleGrantedAuthority(permission.getPermissionName()))
);
}
}
return this.authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
// Les méthodes suivantes peuvent être personnalisées selon les besoins métier
@Override
public boolean isAccountNonExpired() {
return true; // Ou retournez `!user.isExpired()`
}
@Override
public boolean isAccountNonLocked() {
return true; // Ou retournez `!user.isLocked()`
}
@Override
public boolean isCredentialsNonExpired() {
return true; // Ou retournez `!user.isCredentialsExpired()`
}
@Override
public boolean isEnabled() {
return true; // Ou retournez `user.isActive()`
}
}
</permission></simplegrantedauthority>
Dans cette implémentation, l'objet User est supposé contenir les informations de base de l'utilisateur, y compris une référence à son rôle. Chaque rôle peut avoir plusieurs permissions. Notez que les identifiants de rôle doivent commencer par ROLE_ pour être reconnus par Spring Security. Les autres méthodes (isAccountNonExpired, etc.) peuvent être adaptées pour refléter l'état réel du compte utilisateur.
Filtre d'Authentification JWT
Ce filtre est responsable de l'extraction et de la validation du token JWT présent dans les en-têtes des requêtes entrantes.
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final JwtProperties jwtProperties;
private final RedisCache redisCache; // Utilisation d'un cache Redis pour stocker les données utilisateur
public JwtAuthenticationTokenFilter(JwtProperties jwtProperties, RedisCache redisCache) {
this.jwtProperties = jwtProperties;
this.redisCache = redisCache;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestUri = request.getRequestURI();
// Ne pas filtrer l'endpoint de connexion
if ("/auth/login".equals(requestUri)) {
filterChain.doFilter(request, response);
return;
}
// 1. Extraire le token de l'en-tête Authorization
String token = request.getHeader(jwtProperties.getHeaderName()); // Supposant que le nom de l'en-tête est configuré dans JwtProperties
if (token == null || token.isEmpty()) {
// Si aucun token n'est présent, laisser la chaîne de filtres continuer pour qu'un AuthenticationEntryPoint puisse intervenir.
filterChain.doFilter(request, response);
return;
}
// 2. Valider le token et extraire les informations utilisateur
Long userId;
try {
Map<String, Object> claims = JwtUtil.parseToken(jwtProperties.getSecret(), token);
// Conversion sûre de l'ID utilisateur
userId = Long.parseLong(claims.get("userId").toString());
} catch (Exception e) {
log.error("Erreur lors de la validation du token JWT : {}", e.getMessage());
// Gérer l'exception de token expiré ou invalide via l'AuthenticationEntryPoint
// Pour simplifier ici, on laisse le filtre suivant gérer ou on peut lever une exception spécifique
// throw new JwtValidationException("Token invalide ou expiré.");
filterChain.doFilter(request, response); // Laisser le flux continuer pour que les gestionnaires d'exceptions puissent agir
return;
}
// 3. Récupérer les détails de l'utilisateur depuis le cache Redis
String cacheKey = jwtProperties.getCachePrefix() + "_" + userId;
User userFromCache = (User) redisCache.get(cacheKey);
if (userFromCache == null) {
// Si l'utilisateur n'est pas dans le cache, cela peut indiquer un problème ou une déconnexion.
// L'AuthenticationEntryPoint gérera cela si nécessaire.
log.warn("Utilisateur {} non trouvé dans le cache Redis.", userId);
filterChain.doFilter(request, response);
return;
}
// 4. Créer un objet Authentication et le placer dans le SecurityContextHolder
LoginUser loginUser = new LoginUser(userFromCache);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// Continuer la chaîne de filtres
filterChain.doFilter(request, response);
}
}
Service de Chargement des Détails Utilisateur
Ce service est responsable de récupérer les informations de l'utilisateur depuis la base de données lors de la phase d'authentification, en se basant sur le nom d'utilisateur fourni.
@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserMapper userMapper; // Mapper pour interroger la base de données des utilisateurs
public CustomUserDetailsService(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (StringUtils.isEmpty(username)) {
throw new UsernameNotFoundException("Nom d'utilisateur vide fourni.");
}
// Récupérer l'utilisateur depuis la base de données via le mapper
User user = userMapper.selectByUsername(username); // Supposant une méthode dans le mapper pour trouver par nom d'utilisateur
if (user == null) {
throw new UsernameNotFoundException("Aucun utilisateur trouvé avec le nom d'utilisateur : " + username);
}
// Créer et retourner l'objet LoginUser qui implémente UserDetails
return new LoginUser(user);
}
}
Ce service est crucial car il relie l'identificaiton de l'utilisateur (nom d'utilisateur) à ses détails de sécurité (mot de passe, rôles, permissions) nécessaires à Spring Security.
Logique de Connexion Utilisateur
Le service de connexion gère le processus d'authentification initial en utilisant le AuthenticationManager et génère ensuite un token JWT en cas de succès.
@Service
@Slf4j
public class AuthenticationService {
private final AuthenticationManager authenticationManager;
private final RedisCache redisCache;
private final JwtProperties jwtProperties;
private final UserMapper userMapper; // Peut être nécessaire pour récupérer des infos utilisateur après authentification
public AuthenticationService(AuthenticationManager authenticationManager,
RedisCache redisCache,
JwtProperties jwtProperties,
UserMapper userMapper) {
this.authenticationManager = authenticationManager;
this.redisCache = redisCache;
this.jwtProperties = jwtProperties;
this.userMapper = userMapper;
}
public UserLoginResponseVO login(String username, String password) {
// Créer un jeton d'authentification basé sur le nom d'utilisateur et le mot de passe
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
// Authentifier le jeton via le AuthenticationManager
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// Récupérer les détails de l'utilisateur authentifié (notre objet LoginUser)
LoginUser principal = (LoginUser) authentication.getPrincipal();
User user = principal.getUser();
// Stocker les informations utilisateur dans le cache Redis pour une récupération rapide
String cacheKey = jwtProperties.getCachePrefix() + "_" + user.getId();
redisCache.set(cacheKey, user, jwtProperties.getExpirationSeconds(), TimeUnit.SECONDS);
// Générer le token JWT
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
// claims.put("roles", principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())); // Optionnel: ajouter des rôles au payload
String token = JwtUtil.generateToken(jwtProperties.getSecret(), jwtProperties.getExpirationSeconds(), claims);
// Construire la réponse
UserLoginResponseVO loginResponse = new UserLoginResponseVO();
loginResponse.setToken(token);
loginResponse.setUserId(user.getId());
// loginResponse.setRoles(principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())); // Optionnel
return loginResponse;
}
}
Ce processus de connexion illustre la manière dont les différents composants de Spring Security collaborent pour valider les identifiants, sécuriser les informations utilisateur et émettre des tokens d'accès.