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());
}
}