Intégration de Spring Security dans Spring Cloud Gateway

Spring Cloud Gateway

Dans le développement distribué, les microservices sont nombreux, mais la passerelle est le premier point d'entrée des requêtes. Par conséquent, la vérification des autorisations est généralement centralisée au niveau de la passerelle pour l'authentification et l'autorisation. Spring Cloud Gateway, en tant que passerelle de l'écosystème Spring Cloud, vise à remplacer Zuul. Pour améliorer les performances, elle est basée sur le framework WebFlux, qui utilise en couche inférieure le framework de communication Netty basé sur le modèle réactif.

Remarque : En raison de la différence de conteneur web, le projet Gateway utilise WebFlux et ne peut pas être combiné avec Spring Web. Les distinctions entre Spring MVC et WebFlux sont illustrées dans l'image ci-dessous si nécessaire pour la compréhension.

Dépendances

Ajoutez les dépendances suivantes dans votre fichier POM :

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

Configuration de Spring Security

La configuration de Spring Security dans un environnement réactif repose sur WebFilter, similaire à l'approche par Servlet Filter dans Spring MVC. Une série de filtres compose la chaîne de sécurité.

Correspondance des concepts réactifs

Réactif Web traditionnel
@EnableWebFluxSecurity @EnableWebSecurity
ReactiveSecurityContextHolder SecurityContextHolder
AuthenticationWebFilter FilterSecurityInterceptor
ReactiveAuthenticationManager AuthenticationManager
ReactiveUserDetailsService UserDetailsService
ReactiveAuthorizationManager AccessDecisionManager

Activez le support de Spring WebFlux Security avec l'annotation @EnableWebFluxSecurity et configurze la chaîne de filtres de sécurité.

import java.util.LinkedList;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;

@Configuration
@EnableWebFluxSecurity
public class ConfigSecurite {
    @Autowired
    private ConvertisseurAuthentification convertisseurAuthentification;
    @Autowired
    private GestionnaireAutorisations gestionnaireAutorisations;
    @Autowired
    private PointEntreeAuthentification pointEntreeAuthentification;
    @Autowired
    private GestionnaireSuccesAuthJson gestionnaireSuccesAuthJson;
    @Autowired
    private GestionnaireEchecAuthJson gestionnaireEchecAuthJson;
    @Autowired
    private GestionnaireDeconnexionJson gestionnaireDeconnexionJson;
    @Autowired
    private GestionnaireAuthentification gestionnaireAuthentification;
    private static final String[] LISTE_BLANCHE_AUTH = {"/connexion", "/deconnexion"};

    @Bean
    public SecurityWebFilterChain chaineFiltresSecurite(ServerHttpSecurity http) {
        SecurityWebFilterChain chaine = http.formLogin()
                .loginPage("/connexion")
                .authenticationSuccessHandler(gestionnaireSuccesAuthJson)
                .authenticationFailureHandler(gestionnaireEchecAuthJson)
                .authenticationEntryPoint(pointEntreeAuthentification)
                .and()
                .logout()
                .logoutSuccessHandler(gestionnaireDeconnexionJson)
                .and()
                .csrf().disable()
                .httpBasic().disable()
                .authorizeExchange()
                .pathMatchers(LISTE_BLANCHE_AUTH).permitAll()
                .anyExchange().access(gestionnaireAutorisations)
                .and().build();
        chaine.getWebFilters()
                .filter(filtre -> filtre instanceof AuthenticationWebFilter)
                .subscribe(filtre -> {
                    AuthenticationWebFilter filtreAuth = (AuthenticationWebFilter) filtre;
                    filtreAuth.setServerAuthenticationConverter(convertisseurAuthentification);
                });
        return chaine;
    }

    @Bean
    public ReactiveAuthenticationManager gestionnaireAuthentificationReactif() {
        LinkedList<ReactiveAuthenticationManager> gestionnaires = new LinkedList<>();
        gestionnaires.add(gestionnaireAuthentification);
        return new DelegatingReactiveAuthenticationManager(gestionnaires);
    }

    @Bean
    public BCryptPasswordEncoder encodeurMotDePasseBCrypt() {
        return new BCryptPasswordEncoder();
    }
}

Implémentation des gestionnaires spéciaux

Créez des gestionnaires pour les réponses JSON lors des événements d'authentification et de déconnexion.

import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson.JSONObject;
import com.exemple.common.dto.ResultatApi;
import com.exemple.common.dto.CodeErreurApi;
import io.netty.util.CharsetUtil;
import reactor.core.publisher.Mono;

@Component
public class GestionnaireDeconnexionJson implements ServerLogoutSuccessHandler {
    @Override
    public Mono<Void> onLogoutSuccess(WebFilterExchange echange, Authentication authentification) {
        ServerHttpResponse reponse = echange.getExchange().getResponse();
        reponse.setStatusCode(HttpStatus.OK);
        reponse.getHeaders().set(HttpHeaders.CONTENT_TYPE, "application/json; charset=UTF-8");
        String resultat = JSONObject.toJSONString(ResultatApi.restResult("Déconnexion réussie", CodeErreurApi.SUCCES));
        DataBuffer tampon = reponse.bufferFactory().wrap(resultat.getBytes(CharsetUtil.UTF_8));
        return reponse.writeWith(Mono.just(tampon));
    }
}

Convertisseur d'authentification personnalsié

Étendez le convertisseur par défaut pour inclure des paramètres supplémentaires comme le locataire et l'hôte.

import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
public class ConvertisseurAuthentification extends ServerFormLoginAuthenticationConverter {
    private String parametreUtilisateur = "utilisateur";
    private String parametreMotDePasse = "motDePasse";

    @Override
    public Mono<Authentication> convert(ServerWebExchange echange) {
        HttpHeaders enTetes = echange.getRequest().getHeaders();
        String locataire = enTetes.getFirst("_locataire");
        String hote = enTetes.getHost().getHostName();
        return echange.getFormData()
                .map(donnees -> {
                    String utilisateur = donnees.getFirst(this.parametreUtilisateur);
                    String motDePasse = donnees.getFirst(this.parametreMotDePasse);
                    return new JetonAuthentification(utilisateur, motDePasse, locataire, hote);
                });
    }
}

Définissez un jeton d'authentification étendu pour stocker les informations supplémentaires.

import lombok.Getter;
import lombok.Setter;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;

@SuppressWarnings("serial")
@Getter
@Setter
public class JetonAuthentification extends UsernamePasswordAuthenticationToken {
    private String locataire;
    private String hote;

    public JetonAuthentification(Object principal, Object credentials, String locataire, String hote) {
        super(principal, credentials);
        this.locataire = locataire;
        this.hote = hote;
    }

    public JetonAuthentification(Object principal, Object credentials) {
        super(principal, credentials);
    }

    public JetonAuthentification(Object principal, Object credentials, Collection<? extends GrantedAuthority> autorites) {
        super(principal, credentials, autorites);
    }
}

Gestionnaire d'authentification utilisateur

Validez les identifiants utilisateur en utilisant un service réactif et le hachage BCrypt.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractUserDetailsReactiveAuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;

@Component
public class GestionnaireAuthentification extends AbstractUserDetailsReactiveAuthenticationManager {
    private Scheduler planificateur = Schedulers.boundedElastic();
    private PasswordEncoder encodeurMotDePasse = new BCryptPasswordEncoder();
    @Autowired
    private ServiceUtilisateurReactifMySql serviceUtilisateurReactifMySql;

    @Override
    public Mono<Authentication> authenticate(Authentication authentification) {
        JetonAuthentification jeton = (JetonAuthentification) authentification;
        final String nomUtilisateur = authentification.getName();
        final String motDePassePresente = (String) authentification.getCredentials();
        final String locataire = jeton.getLocataire();
        final String hote = jeton.getHote();
        return recupererUtilisateur(nomUtilisateur)
                .publishOn(planificateur)
                .filter(u -> encodeurMotDePasse.matches(motDePassePresente, u.getPassword()))
                .switchIfEmpty(Mono.defer(() -> Mono.error(new BadCredentialsException("Identifiants invalides"))))
                .flatMap(u -> {
                    boolean miseAJourEncodage = serviceUtilisateurReactifMySql != null
                            && encodeurMotDePasse.upgradeEncoding(u.getPassword());
                    if (miseAJourEncodage) {
                        String nouveauMotDePasse = encodeurMotDePasse.encode(motDePassePresente);
                        return serviceUtilisateurReactifMySql.mettreAJourMotDePasse(u, nouveauMotDePasse);
                    }
                    return Mono.just(u);
                })
                .flatMap(detailsUtilisateur -> {
                    // Logique métier supplémentaire ici
                    return Mono.just(detailsUtilisateur);
                })
                .map(u -> new JetonAuthentification(u, u.getPassword(), u.getAuthorities()));
    }

    @Override
    protected Mono<UserDetails> recupererUtilisateur(String nomUtilisateur) {
        return serviceUtilisateurReactifMySql.trouverParNomUtilisateur(nomUtilisateur);
    }
}

Implémentez le service utilisateur réactif pour l'accès aux données.

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class ServiceUtilisateurReactifMySql implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
    private static final String UTILISATEUR_INEXISTANT = "Utilisateur introuvable !";
    private final DepotInformationsCompte depotInformationsCompte;

    public ServiceUtilisateurReactifMySql(DepotInformationsCompte depotInformationsCompte) {
        this.depotInformationsCompte = depotInformationsCompte;
    }

    @Autowired
    BCryptPasswordEncoder encodeurMotDePasseBCrypt;

    @Override
    public Mono<UserDetails> trouverParNomUtilisateur(String nomUtilisateur) {
        return depotInformationsCompte.trouverParNomUtilisateur(nomUtilisateur)
                .switchIfEmpty(Mono.defer(() -> Mono.error(new UsernameNotFoundException(UTILISATEUR_INEXISTANT))))
                .doOnNext(u -> log.info(
                        String.format("Requête compte réussie  utilisateur:%s motDePasse:%s", u.getUsername(), u.getPassword())))
                .cast(UserDetails.class);
    }

    @Override
    public Mono<UserDetails> mettreAJourMotDePasse(UserDetails utilisateur, String nouveauMotDePasse) {
        return depotInformationsCompte.trouverParNomUtilisateur(utilisateur.getUsername())
                .switchIfEmpty(Mono.defer(() -> Mono.error(new UsernameNotFoundException(UTILISATEUR_INEXISTANT))))
                .map(utilisateurTrouve -> {
                    utilisateurTrouve.setPassword(encodeurMotDePasseBCrypt.encode(nouveauMotDePasse));
                    return utilisateurTrouve;
                })
                .flatMap(utilisateurMiseAJour -> depotInformationsCompte.sauvegarder(utilisateurMiseAJour))
                .cast(UserDetails.class);
    }
}

Gestionnaire d'autorisation

Validez les autorisations d'accès aux API en comparant les chemins avec les autorités de l'utilisateur.

import com.alibaba.fastjson.JSONObject;
import com.exemple.common.dto.ResultatApi;
import com.exemple.common.dto.CodeErreurApi;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Collection;

@Slf4j
@Component
public class GestionnaireAutorisations implements ReactiveAuthorizationManager<AuthorizationContext> {
    private AntPathMatcher correspondantChemin = new AntPathMatcher();

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> authentification,
                                             AuthorizationContext contexte) {
        return authentification.map(auth -> {
            ServerWebExchange echange = contexte.getExchange();
            ServerHttpRequest requete = echange.getRequest();
            Collection<? extends GrantedAuthority> autorites = auth.getAuthorities();
            for (GrantedAuthority autorite : autorites) {
                String cheminAutorite = autorite.getAuthority();
                String cheminRequete = requete.getURI().getPath();
                if (correspondantChemin.match(cheminAutorite, cheminRequete)) {
                    log.info(String.format("Vérification API réussie, Autorité:{%s}  Chemin:{%s} ", cheminAutorite, cheminRequete));
                    return new AuthorizationDecision(true);
                }
            }
            return new AuthorizationDecision(false);
        }).defaultIfEmpty(new AuthorizationDecision(false));
    }

    @Override
    public Mono<Void> verify(Mono<Authentication> authentification, AuthorizationContext objet) {
        return check(authentification, objet)
                .filter(d -> d.isGranted())
                .switchIfEmpty(Mono.defer(() -> {
                    ResultatApi<String> resultatApi = ResultatApi.restResult("Accès refusé pour l'utilisateur !", CodeErreurApi.ECHEC);
                    String corps = JSONObject.toJSONString(resultatApi);
                    return Mono.error(new AccessDeniedException(corps));
                }))
                .flatMap(d -> Mono.empty());
    }
}

Étiquettes: SpringCloudGateway SpringSecurity WebFlux ReactiveProgramming Netty

Publié le 12 juin à 20h26