Configuration de Spring Security : Authentification JWT et Gestion des Rôles Statiques

La sécurisation d'une API REST avec Spring Boot nécessite une configuration robuste de Spring Security, particulièrement lors de l'intégration de l'authentification sans état (stateless) via JSON Web Tokens (JWT). Cet article détaille la mise en place d'une architecture basée sur des rôles sttaiques, couvrant la chaîne de filtres, la gestion des utilisateurs et le traitement des exceptions d'accès.

Configuration Principale de la Sécurité

Le point d'entrée de la configuration repose sur la définition d'un SecurityFilterChain. Cette approche moderne remplace l'ancienne classe WebSecurityConfigurerAdapter et permet une configuration plus modulaire. On y désactive la protection CSRF (inutile pour les API stateless), on active le support CORS, et on impose une politique de session strictement sans état.


package io.techblog.security.config;

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.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfiguration {

    private final JwtAuthenticationFilter jwtFilter;
    private final RestAuthenticationEntryPoint authenticationEntryPoint;
    private final RestAccessDeniedHandler accessDeniedHandler;

    public SecurityConfiguration(JwtAuthenticationFilter jwtFilter, 
                                 RestAuthenticationEntryPoint authenticationEntryPoint, 
                                 RestAccessDeniedHandler accessDeniedHandler) {
        this.jwtFilter = jwtFilter;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.accessDeniedHandler = accessDeniedHandler;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configure(http))
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/login").permitAll()
                .anyRequest().authenticated()
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

Modèle du Principal Authentifié

Pour intégrer notre modèle métier avec le contexte de sécurité, il est nécessaire d'implémenter l'interface UserDetails. Cette classe sert de pont entre les données brutes de la base de données et le framework de sécurité, et sera sérialisée dans le cache distribué (ex: Redis).


package io.techblog.security.model;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

public class AuthenticatedUserPrincipal implements UserDetails {

    private final Long userId;
    private final String accountName;
    private final String passwordHash;
    private final boolean isAccountLocked;
    private final List<String> permissions;

    public AuthenticatedUserPrincipal(Long userId, String accountName, String passwordHash, boolean isAccountLocked, List<String> permissions) {
        this.userId = userId;
        this.accountName = accountName;
        this.passwordHash = passwordHash;
        this.isAccountLocked = isAccountLocked;
        this.permissions = permissions;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override public String getPassword() { return passwordHash; }
    @Override public String getUsername() { return accountName; }
    @Override public boolean isAccountNonExpired() { return true; }
    @Override public boolean isAccountNonLocked() { return !isAccountLocked; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled() { return true; }
    
    public Long getUserId() { return userId; }
}

Service de Récupération des Utilisateurs

L'interface UserDetailsService est invoquée lors du processus d'authentification pour charger les informations de l'utilisateur à partir de la persistance.


package io.techblog.security.service;

import io.techblog.security.model.AuthenticatedUserPrincipal;
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;
import java.util.Arrays;

@Service
public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public DatabaseUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity entity = userRepository.findByAccount(username)
                .orElseThrow(() -> new UsernameNotFoundException("Identifiants invalides"));

        // Simulation de récupération des rôles depuis la base de données
        List<String> assignedRoles = Arrays.asList("ROLE_USER", "ROLE_ADMIN", "privilege:read");

        return new AuthenticatedUserPrincipal(
                entity.getId(), 
                entity.getAccount(), 
                entity.getPasswordHash(), 
                entity.isLocked(), 
                assignedRoles
        );
    }
}

Filtre d'Interception JWT

Chaque requête entrante est interceptée par un filtre personnalisé qui étend OncePerRequestFilter. Son rôle est d'extraire le jeton, de le valider, et de reconstituer le contexte de sécurité.


package io.techblog.security.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final DatabaseUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(TokenProvider tokenProvider, DatabaseUserDetailsService userDetailsService) {
        this.tokenProvider = tokenProvider;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 
            throws ServletException, IOException {
        
        String token = extractJwtFromRequest(request);
        
        if (token != null && tokenProvider.validateToken(token)) {
            String username = tokenProvider.getUsernameFromToken(token);
            AuthenticatedUserPrincipal userDetails = (AuthenticatedUserPrincipal) userDetailsService.loadUserByUsername(username);
            
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }

    private String extractJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

Gestion Globale des Exceptions

Pour garantir des réponses API cohérentes, les erreurs d'authentification (401) et d'autorisation (403) sont capturées et formatées en JSON.


package io.techblog.security.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("{\"error\":\"UNAUTHORIZED\", \"message\":\"Authentification requise pour accéder à cette ressource.\"}");
    }
}


package io.techblog.security.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;

@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("{\"error\":\"FORBIDDEN\", \"message\":\"Droits d'accès insuffisants.\"}");
    }
}

Contrôleur d'Authentification

Le point de terminaison de connexion valide les informations d'identification via l'AuthenticationManager et génère un jeton de session.


package io.techblog.controller;

import io.techblog.security.model.AuthenticatedUserPrincipal;
import io.techblog.security.service.TokenProvider;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/auth")
public class AuthenticationController {

    private final AuthenticationManager authenticationManager;
    private final TokenProvider tokenProvider;

    public AuthenticationController(AuthenticationManager authenticationManager, TokenProvider tokenProvider) {
        this.authenticationManager = authenticationManager;
        this.tokenProvider = tokenProvider;
    }

    @PostMapping("/login")
    public AuthResponse login(@RequestBody LoginRequest loginRequest) {
        UsernamePasswordAuthenticationToken authToken = 
            new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
            
        Authentication authentication = authenticationManager.authenticate(authToken);
        AuthenticatedUserPrincipal principal = (AuthenticatedUserPrincipal) authentication.getPrincipal();
        
        String jwt = tokenProvider.generateToken(principal);
        return new AuthResponse(jwt, principal.getUserId());
    }
}

Sécurisation des Méthodes par Rôles

Une fois l'authentification établie, l'accès aux fonctionnalités métier est restreint à l'aide d'annotations basées sur les expressions SpEL. L'exemple ci-dessous démontre la protection d'un point de terminaison nécessitant un privilège spécifique présent dans le contexte de sécurité.


package io.techblog.controller;

import io.techblog.security.model.AuthenticatedUserPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/session")
public class SessionController {

    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    @PostMapping("/terminate")
    public TerminateResponse terminateUserSession() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        AuthenticatedUserPrincipal currentUser = (AuthenticatedUserPrincipal) authentication.getPrincipal();
        
        // Logique de destruction de session ou invalidation de cache ici
        
        return new TerminateResponse("Session terminée pour l'utilisateur ID: " + currentUser.getUserId());
    }
}

Étiquettes: spring-boot spring-security Java JWT rest-api

Publié le 10 juin à 03h55