Authentification centralisée dans les microservices avec Spring Security et OAuth2

La configuration du serveur d'autorisation nécessite l'intégration des dépendances spring-cloud-starter-security et spring-cloud-starter-oauth2.

Les étapes clés incluent :

  • La configuration de WebSecurityConfig en enjectant l'AuthenticationManager et en sruchargeant la méthode configure.
  • La mise en place de la configuration des jetons (TokenConfig).
  • L'extension de AuthorizationServerConfigurerAdapter pour définir les détails des clients et les points de terminaison des jetons.
  • L'implémentation de UserDetailsService en surchargeant loadUserByUsername.

Astuce d'architecture : Pour transmettre des informations utilisateur riches aux autres modules sans créer de classes UserDetails personnalisées complexes, vous pouvez sérialiser l'objet utilisateur de la base de données en JSON et le stocker dans le champ username via le builder de Spring Security. Les services en aval pourront ensuite désérialiser cette chaîne pour récupérer l'entité complète.

Module de passerelle (Gateway)

Le rôle de la passerelle API est de router les requêtes, d'authentifier les utilisateurs et de gérer les listes blanches. L'authentification est centralisée ici, tandis que l'autorisation (les permissions) reste déléguée aux microservices individuels.

Intégration de Spring Security dans la passerelle :

  1. Ajout des dépendances de sécurité et OAuth2.
  2. Création d'un filtre global pour intercepter les requêtes, vérifier les listes blanches et valider les jetons JWT.

Voici une implémentation refactorisée du filtre d'authentification :

@Component
@Slf4j
public class ApiGatewaySecurityFilter implements GlobalFilter, Ordered {

    private static final List<String> PUBLIC_ENDPOINTS = new ArrayList<>();

    static {
        try (InputStream configStream = ApiGatewaySecurityFilter.class.getResourceAsStream("/public-routes.properties")) {
            Properties props = new Properties();
            props.load(configStream);
            PUBLIC_ENDPOINTS.addAll(props.stringPropertyNames());
        } catch (IOException e) {
            log.error("Échec du chargement des routes publiques: {}", e.getMessage());
        }
    }

    @Autowired
    private TokenStore oauth2TokenStore;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String currentPath = exchange.getRequest().getPath().value();
        AntPathMatcher matcher = new AntPathMatcher();

        // Vérification de la liste blanche
        if (PUBLIC_ENDPOINTS.stream().anyMatch(pattern -> matcher.match(pattern, currentPath))) {
            return chain.filter(exchange);
        }

        // Extraction et validation du jeton
        String accessToken = extractBearerToken(exchange);
        if (accessToken == null) {
            return rejectRequest(exchange, "Jeton d'authentification manquant");
        }

        try {
            OAuth2AccessToken token = oauth2TokenStore.readAccessToken(accessToken);
            if (token.isExpired()) {
                return rejectRequest(exchange, "Le jeton a expiré");
            }
            return chain.filter(exchange);
        } catch (InvalidTokenException ex) {
            log.warn("Jeton invalide détecté: {}", accessToken);
            return rejectRequest(exchange, "Jeton invalide");
        }
    }

    private String extractBearerToken(ServerWebExchange exchange) {
        String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);
        }
        return null;
    }

    private Mono<Void> rejectRequest(ServerWebExchange exchange, String errorMessage) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String payload = "{\"error\":\"" + errorMessage + "\"}";
        DataBuffer buffer = response.bufferFactory().wrap(payload.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        return -100;
    }
}

Pour transmettre le cotnexte de sécurité aux filtres suivants dans la chaîne, il est nécessaire de muter la requête, car ServerHttpRequest est immuable :

// Récupération du jeton depuis la requête originale
String bearerToken = extractBearerToken(exchange);

if (bearerToken != null) {
    // Création d'une nouvelle requête avec l'en-tête d'autorisation mis à jour
    ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken)
            .build();

    // Mutation de l'échange avec la nouvelle requête
    ServerWebExchange modifiedExchange = exchange.mutate()
            .request(modifiedRequest)
            .build();

    return chain.filter(modifiedExchange);
}
return chain.filter(exchange);

Configuration des routes publiques dans public-routes.properties :

/api/media/public/**=Accès ouvert aux ressources multimédias

Modules de ressources (Microservices aval)

Dans les autres microservices, toutes les routes sont généralement autorisées au niveau du filtre de sécurité, car l'authentification a déjà été validée par la passerelle. Ces services se concentrent uniquement sur l'autorisation.

Pour extraire les informations de l'utilisateur authentifié, on utilise le SecurityContextHolder. Voici un utilitaire pour décoder les informations transmises :

@Slf4j
public class UserContextExtractor {

    public static AuthenticatedUser extractCurrentUser() {
        try {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth != null && auth.getPrincipal() instanceof String) {
                String userPayload = (String) auth.getPrincipal();
                return JsonUtil.deserialize(userPayload, AuthenticatedUser.class);
            }
        } catch (Exception e) {
            log.error("Erreur lors de l'extraction du contexte utilisateur: {}", e.getMessage());
        }
        return null;
    }

    @Data
    public static class AuthenticatedUser implements Serializable {
        private String identifier;
        private String login;
        private String displayName;
        private String avatarUrl;
        private String emailAddress;
        private String phoneNumber;
        private String accountStatus;
        private LocalDateTime registrationDate;
    }
}

Note sur l'implémentation : La classe AuthenticatedUser est définie comme une classe interne statique. Elle doit être instanciée ou référencée via la classe englobante (ex: UserContextExtractor.AuthenticatedUser).

Analyse technique des mécanismes sous-jacents

Transmission du jeton à travers la passerelle

Sans intégration OAuth2 native, la modification de la requête entrante est nécessaire. Étant donné que ServerHttpRequest est immuable dans Spring WebFlux, l'ajout ou la modification d'en-têtes (comme le token) exige la création d'une nouvelle instance de requête et le remplacement de l'objet ServerWebExchange avant de passer le relais au GatewayFilterChain. Avec OAuth2, la chaîne de filtres de Spring Security intercepte automatiquement le jeton et le place dans le contexte de sécurité, permettant aux filtres en aval d'y accéder via les API standard.

Isolation et reconstruction du contexte de sécurité

Chaque microservice s'exécute dans sa propre machine virtuelle Java (JVM) avec ses propres threads. Par conséquent, le SecurityContext est strictement local à chaque thread et ne peut pas être partagé directement entre les services. Lorsqu'un microservice reçoit une requête contenant un jeton OAuth2, le filtre de ressource OAuth2 valide ce jeton et reconstruit un nouveau SecurityContext spécifique au thread courant. C'est ce mécanisme qui permet d'appeler SecurityContextHolder.getContext().getAuthentication() de manière fiable dans n'importe quel service aval, bien que les contextes soient techniquement distincts.

Étiquettes: spring-security oauth2 spring-cloud-gateway Microservices JWT

Publié le 21 juin à 02h27