Processus d'authentification et d'autorisation dans Spring Security

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

  1. Un utilisateur soumet ses identifiants (généralement nom d'utilisateur et mot de passe) pour se connecter.
  2. 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.
  3. Spring Security encapsule les identifiants fournis dans un objet UsernamePasswordAuthenticationToken, qui implémente l'interface Authentication.
  4. L'objet AuthenticationManager est chargé d'authentifier ce jeton. L'appel authenticationManager.authenticate(authenticationToken) déclenche la vérification des identifiants.
  5. L'AuthenticationManager effectue l'authentification :
    • En cas de succès, il renvoie un objet Authentication contenant les détails de l'utilisateur, y compris ses rôles et permissions.
    • En cas d'échec, une exception AuthenticationException est levée. Cette exception est interceptée et gérée par un AuthenticationEntryPoint, qui peut guider l'utilisateur vers une nouvelle tentative de connexion.
  6. L'objet Authentication renvoyé par le gestionnaire est stocké dans le contexte de sécurité actuel à l'aide de SecurityContextHolder.getContext().setAuthentication(...).
  7. Lors de l'accès à une ressource protégée après une authentification réussie, l'objet Authentication stocké est utilisé pour vérifier les permissions de l'utilisateur. Si l'accès est refusé, une exception AccessDeniedException est levée, traitée par le ExceptionTranslationFilter, 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.

Étiquettes: Spring Security Java Authentification autorisation JWT

Publié le 23 juillet à 13h55