diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java index 83de87c929..6408249eb7 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java @@ -25,16 +25,20 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.aop.support.AopUtils; import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory; import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder; import org.springframework.cloud.client.circuitbreaker.Customizer; import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.cloud.gateway.config.GlobalCorsProperties; -import org.springframework.cloud.gateway.config.HttpClientCustomizer; +import org.springframework.cloud.gateway.config.HttpClientProperties; +import org.springframework.cloud.gateway.filter.headers.HttpHeadersFilter; import org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping; import org.springframework.cloud.netflix.eureka.CloudEurekaClient; import org.springframework.cloud.netflix.eureka.EurekaClientConfigBean; @@ -44,7 +48,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.StandardEnvironment; +import org.springframework.context.annotation.Primary; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; @@ -147,7 +151,6 @@ public void updateConfigParameters() { } } - public HttpsFactory factory() { HttpsConfig config = HttpsConfig.builder() .protocol(protocol) @@ -162,26 +165,56 @@ public HttpsFactory factory() { return new HttpsFactory(config); } + /** + * This bean processor is used to override bean routingFilter defined at + * org.springframework.cloud.gateway.config.GatewayAutoConfiguration.NettyConfiguration#routingFilter(HttpClient, ObjectProvider, HttpClientProperties) + * + * There is no simple way how to override this specific bean, but bean processing could handle that. + * + * @param httpClient default http client + * @param headersFiltersProvider header filter for spring cloud gateway router + * @param properties client HTTP properties + * @return bean processor to replace NettyRoutingFilter by NettyRoutingFilterApiml + */ @Bean - HttpClientCustomizer secureCustomizer() { - return httpClient -> httpClient.secure(b -> b.sslContext(sslContext())); + public BeanPostProcessor routingFilterHandler(HttpClient httpClient, ObjectProvider> headersFiltersProvider, HttpClientProperties properties) { + // obtain SSL contexts (one with keystore to support client cert sign and truststore, second just with truststore) + SslContext justTruststore = sslContext(false); + SslContext withKeystore = sslContext(true); + + return new BeanPostProcessor() { + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + if ("routingFilter".equals(beanName)) { + // once is creating original bean by autoconfiguration replace it with custom implementation + return new NettyRoutingFilterApiml(httpClient, headersFiltersProvider, properties, justTruststore, withKeystore); + } + // do not touch any other bean + return bean; + } + }; } - /** * @return io.netty.handler.ssl.SslContext for http client. */ - SslContext sslContext() { + SslContext sslContext(boolean setKeystore) { try { - KeyStore keyStore = SecurityUtils.loadKeyStore(keyStoreType, keyStorePath, keyStorePassword); - KeyStore trustStore = SecurityUtils.loadKeyStore(trustStoreType, trustStorePath, trustStorePassword); + SslContextBuilder builder = SslContextBuilder.forClient(); - KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - keyManagerFactory.init(keyStore, keyStorePassword); + KeyStore trustStore = SecurityUtils.loadKeyStore(trustStoreType, trustStorePath, trustStorePassword); TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init(trustStore); - return SslContextBuilder.forClient().keyManager(keyManagerFactory).trustManager(trustManagerFactory).build(); + builder.trustManager(trustManagerFactory); + + if (setKeystore) { + KeyStore keyStore = SecurityUtils.loadKeyStore(keyStoreType, keyStorePath, keyStorePassword); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, keyStorePassword); + builder.keyManager(keyManagerFactory); + } + + return builder.build(); } catch (Exception e) { apimlLog.log("org.zowe.apiml.common.sslContextInitializationError", e.getMessage()); throw new HttpsConfigError("Error initializing SSL Context: " + e.getMessage(), e, @@ -216,7 +249,7 @@ public CloudEurekaClient primaryEurekaClient(ApplicationInfoManager manager, Eur } @Bean - public List additionalRegistration(StandardEnvironment environment) { + public List additionalRegistration() { List additionalRegistrations = new AdditionalRegistrationParser().extractAdditionalRegistrations(System.getenv()); log.debug("Parsed {} additional registration: {}", additionalRegistrations.size(), additionalRegistrations); return additionalRegistrations; @@ -267,10 +300,15 @@ public Customizer defaultCustomizer() } @Bean - public WebClient webClient() { - HttpClient client = HttpClient.create().secure(ssl -> ssl.sslContext(sslContext())); - return WebClient.builder().clientConnector(new ReactorClientHttpConnector(client)).build(); + @Primary + public WebClient webClient(HttpClient httpClient) { + return WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)).build(); + } + @Bean + public WebClient webClientClientCert(HttpClient httpClient) { + httpClient = httpClient.secure(sslContextSpec -> sslContextSpec.sslContext(sslContext(true))); + return webClient(httpClient); } @Bean diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/NettyRoutingFilterApiml.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/NettyRoutingFilterApiml.java new file mode 100644 index 0000000000..6a8d0eb6e1 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/NettyRoutingFilterApiml.java @@ -0,0 +1,76 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.ssl.SslContext; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cloud.gateway.config.HttpClientProperties; +import org.springframework.cloud.gateway.filter.NettyRoutingFilter; +import org.springframework.cloud.gateway.filter.headers.HttpHeadersFilter; +import org.springframework.cloud.gateway.route.Route; +import org.springframework.web.server.ServerWebExchange; +import reactor.netty.http.client.HttpClient; + +import java.util.List; +import java.util.Optional; + +import static org.springframework.cloud.gateway.support.RouteMetadataUtils.CONNECT_TIMEOUT_ATTR; +import static org.zowe.apiml.constants.ApimlConstants.HTTP_CLIENT_USE_CLIENT_CERTIFICATE; + +public class NettyRoutingFilterApiml extends NettyRoutingFilter { + + private final HttpClient httpClientNoCert; + private final HttpClient httpClientClientCert; + + public NettyRoutingFilterApiml( + HttpClient httpClient, + ObjectProvider> headersFiltersProvider, + HttpClientProperties properties, + SslContext justTruststore, + SslContext withKeystore + ) { + super(null, headersFiltersProvider, properties); + + // construct http clients with different SSL configuration - with / without client certs + httpClientNoCert = httpClient.secure(sslContextSpec -> sslContextSpec.sslContext(justTruststore)); + httpClientClientCert = httpClient.secure(sslContextSpec -> sslContextSpec.sslContext(withKeystore)); + } + + static Integer getInteger(Object connectTimeoutAttr) { + Integer connectTimeout; + if (connectTimeoutAttr instanceof Integer) { + connectTimeout = (Integer) connectTimeoutAttr; + } + else { + connectTimeout = Integer.parseInt(connectTimeoutAttr.toString()); + } + return connectTimeout; + } + + @Override + protected HttpClient getHttpClient(Route route, ServerWebExchange exchange) { + // select proper HttpClient instance by attribute apiml.useClientCert + boolean useClientCert = Optional.ofNullable((Boolean) exchange.getAttribute(HTTP_CLIENT_USE_CLIENT_CERTIFICATE)).orElse(Boolean.FALSE); + HttpClient httpClient = useClientCert ? httpClientClientCert : httpClientNoCert; + + Object connectTimeoutAttr = route.getMetadata().get(CONNECT_TIMEOUT_ATTR); + if (connectTimeoutAttr != null) { + // if there is configured timeout, respect it + Integer connectTimeout = getInteger(connectTimeoutAttr); + return httpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout); + } + + // otherwise just return selected HttpClient + return httpClient; + } + +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java index afd52d9264..dca6bb2bde 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java @@ -26,9 +26,6 @@ public class RoutingConfig { @Value("${apiml.service.ignoredHeadersWhenCorsEnabled:-}") private String ignoredHeadersWhenCorsEnabled; - @Value("${apiml.service.forwardClientCertEnabled:false}") - private String forwardingClientCertEnabled; - @Bean public List filters() { FilterDefinition circuitBreakerFilter = new FilterDefinition(); @@ -39,14 +36,9 @@ public List filters() { retryFilter.addArg("retries", "5"); retryFilter.addArg("statuses", "SERVICE_UNAVAILABLE"); - FilterDefinition clientCertFilter = new FilterDefinition(); - clientCertFilter.setName("ClientCertFilterFactory"); - clientCertFilter.addArg("forwardingEnabled", forwardingClientCertEnabled); - List filters = new ArrayList<>(); filters.add(circuitBreakerFilter); filters.add(retryFilter); - filters.add(clientCertFilter); for (String headerName : ignoredHeadersWhenCorsEnabled.split(",")) { FilterDefinition removeHeaders = new FilterDefinition(); removeHeaders.setName("RemoveRequestHeader"); diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/AbstractAuthSchemeFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/AbstractAuthSchemeFactory.java index f75b2d78d2..0086e6c4fc 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/AbstractAuthSchemeFactory.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/AbstractAuthSchemeFactory.java @@ -34,6 +34,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.zowe.apiml.cloudgatewayservice.filters.ClientCertFilterFactory.CLIENT_CERT_HEADER; import static org.zowe.apiml.constants.ApimlConstants.PAT_COOKIE_AUTH_NAME; import static org.zowe.apiml.constants.ApimlConstants.PAT_HEADER_NAME; import static org.zowe.apiml.security.SecurityUtils.COOKIE_AUTH_NAME; @@ -140,6 +141,7 @@ public abstract class AbstractAuthSchemeFactory robinRound = new RobinRoundIterator<>(); diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java index 3d65b340aa..d015b83266 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java @@ -10,7 +10,6 @@ package org.zowe.apiml.cloudgatewayservice.filters; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; @@ -22,6 +21,8 @@ import java.security.cert.X509Certificate; import java.util.Base64; +import static org.zowe.apiml.constants.ApimlConstants.HTTP_CLIENT_USE_CLIENT_CERTIFICATE; + /** * Objective is to include new header in the request which contains incoming client certificate * so that further processing (mapping to mainframe userId) is possible by the domain gateway. @@ -30,7 +31,7 @@ @Slf4j public class ClientCertFilterFactory extends AbstractGatewayFilterFactory { - private static final String CLIENT_CERT_HEADER = "Client-Cert"; + public static final String CLIENT_CERT_HEADER = "Client-Cert"; public ClientCertFilterFactory() { super(Config.class); @@ -49,11 +50,12 @@ public GatewayFilter apply(Config config) { return ((exchange, chain) -> { ServerHttpRequest request = exchange.getRequest().mutate().headers(headers -> { headers.remove(CLIENT_CERT_HEADER); - if (config.isForwardingEnabled() && exchange.getRequest().getSslInfo() != null) { + if (exchange.getRequest().getSslInfo() != null) { X509Certificate[] certificates = exchange.getRequest().getSslInfo().getPeerCertificates(); if (certificates != null && certificates.length > 0) { try { final String encodedCert = Base64.getEncoder().encodeToString(certificates[0].getEncoded()); + exchange.getAttributes().put(HTTP_CLIENT_USE_CLIENT_CERTIFICATE, Boolean.TRUE); headers.add(CLIENT_CERT_HEADER, encodedCert); log.debug("Incoming client certificate has been added to the {} header.", CLIENT_CERT_HEADER); } catch (CertificateEncodingException e) { @@ -67,12 +69,8 @@ public GatewayFilter apply(Config config) { }); } + @SuppressWarnings("squid:S2094") public static class Config { - @Setter - private String forwardingEnabled; - - public boolean isForwardingEnabled() { - return Boolean.parseBoolean(forwardingEnabled); - } } + } diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java index 2ef091b182..03dd710b12 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java @@ -16,6 +16,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.apache.http.HttpHeaders; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; @@ -38,7 +39,7 @@ public class PassticketFilterFactory extends AbstractAuthSchemeFactory { @@ -51,6 +53,7 @@ public GatewayFilter apply(Config config) { if (certificates != null && certificates.length > 0) { ServerHttpRequest request = exchange.getRequest().mutate().headers(headers -> { try { + exchange.getAttributes().put(HTTP_CLIENT_USE_CLIENT_CERTIFICATE, Boolean.TRUE); setHeader(headers, config.getHeaders().split(","), certificates[0]); } catch (CertificateEncodingException | InvalidNameException e) { headers.add(ApimlConstants.AUTH_FAIL_HEADER, "Invalid client certificate in request. Error message: " + e.getMessage()); diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ZosmfFilterFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ZosmfFilterFactory.java index 971c5d4882..a78a5aad0a 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ZosmfFilterFactory.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ZosmfFilterFactory.java @@ -11,6 +11,7 @@ package org.zowe.apiml.cloudgatewayservice.filters; import lombok.EqualsAndHashCode; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; @@ -32,7 +33,7 @@ public class ZosmfFilterFactory extends AbstractAuthSchemeFactory apimlGatewayLookup; private final Cache> apimlServicesCache; - private final WebClient defaultWebClient; - private SslContext customClientSslContext = null; - - public GatewayIndexService(WebClient defaultWebClient, - @Value("${apiml.cloudGateway.cachePeriodSec:120}") int cachePeriodSec, - @Value("${apiml.cloudGateway.clientKeystore:#{null}}") String clientKeystorePath, - @Value("${apiml.cloudGateway.clientKeystorePassword:#{null}}") char[] clientKeystorePassword, - @Value("${apiml.cloudGateway.clientKeystoreType:PKCS12}") String keystoreType + private final WebClient webClient; + + public GatewayIndexService( + @Qualifier("webClientClientCert") WebClient webClient, + @Value("${apiml.cloudGateway.cachePeriodSec:120}") int cachePeriodSec ) { - this.defaultWebClient = defaultWebClient; + this.webClient = webClient; apimlGatewayLookup = CacheBuilder.newBuilder().expireAfterWrite(cachePeriodSec, SECONDS).build(); apimlServicesCache = CacheBuilder.newBuilder().expireAfterWrite(cachePeriodSec, SECONDS).build(); - - if (isNotBlank(clientKeystorePath) && nonNull(clientKeystorePassword)) { - customClientSslContext = load(clientKeystorePath, clientKeystorePassword, keystoreType); - } } private WebClient buildWebClient(ServiceInstance registration) { final String baseUrl = String.format("%s://%s:%d", registration.getScheme(), registration.getHost(), registration.getPort()); - if (this.customClientSslContext != null) { - SslProvider sslProvider = SslProvider.builder().sslContext(customClientSslContext).build(); - HttpClient httpClient = HttpClient.create() - .secure(sslProvider); - - return WebClient.builder() - .baseUrl(baseUrl) - .clientConnector(new ReactorClientHttpConnector(httpClient)) - .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE) - .build(); - } - return defaultWebClient.mutate() + return webClient.mutate() .baseUrl(baseUrl) + .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE) .build(); } @@ -116,11 +88,10 @@ public void putApimlServices(@NotNull String apimlId, List services } private Mono> fetchServices(String apimlId, ServiceInstance registration) { - WebClient webClient = buildWebClient(registration); final ParameterizedTypeReference> serviceInfoType = new ParameterizedTypeReference>() { }; - return webClient.get().uri("/gateway/services") + return buildWebClient(registration).get().uri("/gateway/services") .retrieve() .bodyToMono(serviceInfoType) .doOnNext(foreignServices -> apimlServicesCache.put(apimlId, foreignServices)); diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java index c2b5e38776..188b5d542c 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java @@ -12,6 +12,7 @@ import lombok.AccessLevel; import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient; import org.springframework.cloud.gateway.filter.FilterDefinition; @@ -30,21 +31,22 @@ import org.zowe.apiml.util.StringUtils; import reactor.core.publisher.Flux; -import java.util.Comparator; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.SERVICE_SUPPORTING_CLIENT_CERT_FORWARDING; @Service public class RouteLocator implements RouteDefinitionLocator { private static final EurekaMetadataParser metadataParser = new EurekaMetadataParser(); + @Value("${apiml.service.forwardClientCertEnabled:false}") + private boolean forwardingClientCertEnabled; + private final ApplicationContext context; private final CorsUtils corsUtils; @@ -112,6 +114,54 @@ Stream getRoutedService(ServiceInstance serviceInstance) { .sorted(Comparator.comparingInt(x -> StringUtils.removeFirstAndLastOccurrence(x.getGatewayUrl(), "/").length()).reversed()); } + static List join(List a, List b) { + if (b.isEmpty()) return a; + + List output = new LinkedList<>(a); + output.addAll(b); + return output; + } + + List getPostRoutingFilters(ServiceInstance serviceInstance) { + List serviceRelated = new LinkedList<>(); + if ( + forwardingClientCertEnabled && + Optional.ofNullable(serviceInstance.getMetadata().get(SERVICE_SUPPORTING_CLIENT_CERT_FORWARDING)) + .map(Boolean::parseBoolean).orElse(false) + ) { + FilterDefinition clientCertFilter = new FilterDefinition(); + clientCertFilter.setName("ClientCertFilterFactory"); + serviceRelated.add(clientCertFilter); + } + + return join(commonFilters, serviceRelated); + } + + private List getAuthFilterPerRoute( + AtomicInteger orderHolder, + ServiceInstance serviceInstance, + List postRoutingFilters + ) { + Authentication auth = metadataParser.parseAuthentication(serviceInstance.getMetadata()); + // iterate over routing definition (ordered from the longest one to match with the most specific) + return getRoutedService(serviceInstance) + .map(routedService -> + routeDefinitionProducers.stream() + .sorted(Comparator.comparingInt(x -> x.getOrder())) + .map(rdp -> { + // generate a new routing rule by a specific produces + RouteDefinition routeDefinition = rdp.get(serviceInstance, routedService); + routeDefinition.setOrder(orderHolder.getAndIncrement()); + routeDefinition.getFilters().addAll(postRoutingFilters); + setAuth(serviceInstance, routeDefinition, auth); + + return routeDefinition; + }).collect(Collectors.toList()) + ) + .flatMap(List::stream) + .collect(Collectors.toList()); + } + /** * It generates each rule for each combination of instance x routing x generator ({@link RouteDefinitionProducer}) * The routes are sorted by serviceUrl to avoid clashing between multiple levels of paths, ie. / vs. /a. @@ -122,29 +172,16 @@ Stream getRoutedService(ServiceInstance serviceInstance) { */ @Override public Flux getRouteDefinitions() { + // counter of generated route definition to prevent clashing by the order AtomicInteger order = new AtomicInteger(); // iterate over services return getServiceInstances().flatMap(Flux::fromIterable).map(serviceInstance -> { - Authentication auth = metadataParser.parseAuthentication(serviceInstance.getMetadata()); // configure CORS for the service (if necessary) setCors(serviceInstance); - // iterate over routing definition (ordered from the longest one to match with the most specific) - return getRoutedService(serviceInstance) - .map(routedService -> - routeDefinitionProducers.stream() - .sorted(Comparator.comparingInt(x -> x.getOrder())) - .map(rdp -> { - // generate a new routing rule by a specific produces - RouteDefinition routeDefinition = rdp.get(serviceInstance, routedService); - routeDefinition.setOrder(order.getAndIncrement()); - routeDefinition.getFilters().addAll(commonFilters); - setAuth(serviceInstance, routeDefinition, auth); - return routeDefinition; - }).collect(Collectors.toList()) - ).collect(Collectors.toList()); + // generate route definition per services and its routing rules + return getAuthFilterPerRoute(order, serviceInstance, getPostRoutingFilters(serviceInstance)); }) - .flatMapIterable(list -> list) .flatMapIterable(list -> list); } diff --git a/cloud-gateway-service/src/main/resources/application.yml b/cloud-gateway-service/src/main/resources/application.yml index bf7a28e40d..02a63d409f 100644 --- a/cloud-gateway-service/src/main/resources/application.yml +++ b/cloud-gateway-service/src/main/resources/application.yml @@ -6,7 +6,9 @@ eureka: healthCheckUrl: ${apiml.service.scheme}://${apiml.service.hostname}:${apiml.service.port}/application/health metadata-map: apiml: - service.apimlId : ${apiml.service.apimlId} + service: + apimlId: ${apiml.service.apimlId} + supportClientCertForwarding: true client: fetchRegistry: true registerWithEureka: true diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/ZosmfSchemeTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/ZosmfSchemeTest.java index 613ff48b82..05a43c24d5 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/ZosmfSchemeTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/ZosmfSchemeTest.java @@ -287,6 +287,7 @@ void createService() throws IOException { .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-Public"))) .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-DistinguishedName"))) .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-CommonName"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("Client-Cert"))) .assertion(he -> assertEquals("myvalue", he.getRequestHeaders().getFirst("myheader"))) .assertion(he -> assertNull(getCookie(he, "personalAccessToken"))) @@ -312,6 +313,7 @@ void givenMultipleHeaders_whenCallingAService_thenTheyAreResend() { .header("X-Certificate-Public", "X-Certificate-Public") .header("X-Certificate-DistinguishedName", "X-Certificate-DistinguishedName") .header("X-Certificate-CommonName", "X-Certificate-CommonName") + .header("Client-Cert", "certData") .cookie("mycookie", "mycookievalue") .cookie("personalAccessToken", "pat") diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/config/NettyRoutingFilterApimlTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/config/NettyRoutingFilterApimlTest.java new file mode 100644 index 0000000000..f592c1a7db --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/config/NettyRoutingFilterApimlTest.java @@ -0,0 +1,163 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.cloud.gateway.route.Route; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.netty.http.client.HttpClient; +import reactor.netty.tcp.SslProvider; + +import javax.net.ssl.SSLException; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.cloud.gateway.support.RouteMetadataUtils.CONNECT_TIMEOUT_ATTR; +import static org.zowe.apiml.constants.ApimlConstants.HTTP_CLIENT_USE_CLIENT_CERTIFICATE; + +class NettyRoutingFilterApimlTest { + + @Nested + class Parsing { + + @Test + void givenInteger_whenGetInteger_thenConvert() { + assertEquals(new Integer(157), NettyRoutingFilterApiml.getInteger(157)); + } + + @Test + void givenNumberString_whenGetInteger_thenParse() { + assertEquals(new Integer(759), NettyRoutingFilterApiml.getInteger("759")); + } + + @Test + void givenNull_whenGetInteger_thenThrowNullPointerException() { + assertThrows(NullPointerException.class, () -> NettyRoutingFilterApiml.getInteger(null)); + } + + @Test + void givenNonNumericValue_whenGetInteger_thenThrowNumberFormatException() { + assertThrows(NumberFormatException.class, () -> NettyRoutingFilterApiml.getInteger("nonNumeric")); + } + + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class HttpClientChooser { + + SslContext sslContextNoCert; + SslContext sslContextClientCert; + + HttpClient httpClientNoCert = mock(HttpClient.class); + HttpClient httpClientClientCert = mock(HttpClient.class); + + HttpClient httpClient = mock(HttpClient.class); + + @BeforeAll + void initHttpClients() throws SSLException { + sslContextNoCert = SslContextBuilder.forClient().build(); + sslContextClientCert = SslContextBuilder.forClient().build(); + + // mock calling of HttpClient.secure to handle thw both cases - with and without client cert + doAnswer(answer -> { + Consumer consumer = answer.getArgument(0); + + // detect what sslContext was provided - see sslContextArg + SslProvider.SslContextSpec sslContextSpec = mock(SslProvider.SslContextSpec.class); + ArgumentCaptor sslContextArg = ArgumentCaptor.forClass(SslContext.class); + consumer.accept(sslContextSpec); + verify(sslContextSpec).sslContext(sslContextArg.capture()); + + // return the related http client instance + if (sslContextArg.getValue() == sslContextNoCert) { + return httpClientNoCert; + } + if (sslContextArg.getValue() == sslContextClientCert) { + return httpClientClientCert; + } + fail("Received unexpencted SSL config"); + return null; + }).when(httpClient).secure(Mockito.>any()); + } + + @Test + void givenDefaultHttpClient_whenCreatingAInstance_thenBothHttpClientsAreCreatedWell() { + NettyRoutingFilterApiml nettyRoutingFilterApiml = new NettyRoutingFilterApiml(httpClient, null, null, sslContextNoCert, sslContextClientCert); + + // verify if proper httpClient instances were created + assertSame(httpClientNoCert, ReflectionTestUtils.getField(nettyRoutingFilterApiml, "httpClientNoCert")); + assertSame(httpClientClientCert, ReflectionTestUtils.getField(nettyRoutingFilterApiml, "httpClientClientCert")); + } + + @Nested + class GetHttpClient { + + NettyRoutingFilterApiml nettyRoutingFilterApiml; + private final Route ROUTE_NO_TIMEOUT = Route.async() + .id("1").uri("http://localhost/").predicate(serverWebExchange -> true) + .build(); + private final Route ROUTE_TIMEOUT = Route.async() + .id("2").uri("http://localhost/").predicate(serverWebExchange -> true).metadata(CONNECT_TIMEOUT_ATTR, "100") + .build(); + MockServerWebExchange serverWebExchange; + + @BeforeEach + void initMocks() { + nettyRoutingFilterApiml = new NettyRoutingFilterApiml(httpClient, null, null, sslContextNoCert, sslContextClientCert); + + MockServerHttpRequest mockServerHttpRequest = MockServerHttpRequest.get("/path").build(); + serverWebExchange = MockServerWebExchange.from(mockServerHttpRequest); + } + + @Test + void givenNoTimeoutAndNoRequirementsForClientCert_whenGetHttpClient_thenCallWithoutClientCert() { + assertSame(httpClientNoCert, nettyRoutingFilterApiml.getHttpClient(ROUTE_NO_TIMEOUT, serverWebExchange)); + } + + @Test + void givenNoTimeoutAndFalseAsRequirementsForClientCert_whenGetHttpClient_thenCallWithoutClientCert() { + serverWebExchange.getAttributes().put(HTTP_CLIENT_USE_CLIENT_CERTIFICATE, Boolean.FALSE); + assertSame(httpClientNoCert, nettyRoutingFilterApiml.getHttpClient(ROUTE_NO_TIMEOUT, serverWebExchange)); + } + + @Test + void givenNoTimeoutAndRequirementsForClientCert_whenGetHttpClient_thenCallWithoutClientCert() { + serverWebExchange.getAttributes().put(HTTP_CLIENT_USE_CLIENT_CERTIFICATE, Boolean.TRUE); + assertSame(httpClientClientCert, nettyRoutingFilterApiml.getHttpClient(ROUTE_NO_TIMEOUT, serverWebExchange)); + } + + @Test + void givenTimeoutAndNoRequirementsForClientCert_whenGetHttpClient_thenCallWithoutClientCert() { + nettyRoutingFilterApiml.getHttpClient(ROUTE_TIMEOUT, serverWebExchange); + verify(httpClientNoCert).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 100); + } + + @Test + void givenTimeoutAndRequirementsForClientCert_whenGetHttpClient_thenCallWithoutClientCert() { + serverWebExchange.getAttributes().put(HTTP_CLIENT_USE_CLIENT_CERTIFICATE, Boolean.TRUE); + nettyRoutingFilterApiml.getHttpClient(ROUTE_TIMEOUT, serverWebExchange); + verify(httpClientClientCert).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 100); + } + + } + + } + +} \ No newline at end of file diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java index d4ca5b86c0..37f5e88207 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java @@ -13,8 +13,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.http.HttpHeaders; @@ -32,11 +30,14 @@ import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.zowe.apiml.constants.ApimlConstants.HTTP_CLIENT_USE_CLIENT_CERTIFICATE; class ClientCertFilterFactoryTest { @@ -67,6 +68,9 @@ void setup() { when(request.mutate()).thenReturn(requestBuilder); when(exchange.mutate()).thenReturn(exchangeBuilder); when(chain.filter(exchange)).thenReturn(Mono.empty()); + + Map attributes = new HashMap<>(); + when(exchange.getAttributes()).thenReturn(attributes); } @Nested @@ -78,8 +82,7 @@ void setup() throws CertificateException { } @Test - void whenEnabled_thenAddHeaderToRequest() { - filterConfig.setForwardingEnabled("true"); + void whenFilter_thenAddHeaderToRequest() { GatewayFilter filter = filterFactory.apply(filterConfig); Mono result = filter.filter(exchange, chain); result.block(); @@ -87,17 +90,7 @@ void whenEnabled_thenAddHeaderToRequest() { assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); assertNotNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); assertEquals(ENCODED_CERT, exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER).get(0)); - } - - @Test - void whenDisabled_thenNoHeadersInRequest() { - filterConfig.setForwardingEnabled("false"); - GatewayFilter filter = filterFactory.apply(filterConfig); - Mono result = filter.filter(exchange, chain); - result.block(); - - assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); - assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + assertEquals(Boolean.TRUE, exchange.getAttributes().get(HTTP_CLIENT_USE_CLIENT_CERTIFICATE)); } @Nested @@ -109,8 +102,7 @@ void setup() { } @Test - void whenEnabled_thenHeaderContainsNewValue() { - filterConfig.setForwardingEnabled("true"); + void whenFilter_thenHeaderContainsNewValue() { GatewayFilter filter = filterFactory.apply(filterConfig); Mono result = filter.filter(exchange, chain); result.block(); @@ -120,17 +112,6 @@ void whenEnabled_thenHeaderContainsNewValue() { assertEquals(ENCODED_CERT, exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER).get(0)); } - @Test - void whenDisabled_thenNoHeadersInRequest() { - filterConfig.setForwardingEnabled("false"); - GatewayFilter filter = filterFactory.apply(filterConfig); - Mono result = filter.filter(exchange, chain); - result.block(); - - assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); - assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); - } - @Nested class WhenNoSSLSessionInformation { @@ -139,10 +120,8 @@ void setup() { when(request.getSslInfo()).thenReturn(null); } - @ParameterizedTest - @ValueSource(strings = {"true", "false"}) - void thenNoHeadersInRequest(String enabled) { - filterConfig.setForwardingEnabled(enabled); + @Test + void thenNoHeadersInRequest() { GatewayFilter filter = filterFactory.apply(filterConfig); Mono result = filter.filter(exchange, chain); result.block(); @@ -161,10 +140,8 @@ void setup() { when(request.getSslInfo()).thenReturn(null); } - @ParameterizedTest - @ValueSource(strings = {"true", "false"}) - void thenNoHeadersInRequest(String enabled) { - filterConfig.setForwardingEnabled(enabled); + @Test + void thenNoHeadersInRequest() { GatewayFilter filter = filterFactory.apply(filterConfig); Mono result = filter.filter(exchange, chain); result.block(); @@ -181,7 +158,6 @@ class GivenInvalidCertificateInRequest { @BeforeEach void setup() throws CertificateEncodingException { - filterConfig.setForwardingEnabled("true"); requestBuilder.header(CLIENT_CERT_HEADER, "This value cannot pass through the filter."); when(x509Certificates[0].getEncoded()).thenThrow(new CertificateEncodingException("incorrect encoding")); } @@ -208,10 +184,8 @@ void setup() { when(sslInfo.getPeerCertificates()).thenReturn(new X509Certificate[0]); } - @ParameterizedTest - @ValueSource(strings = {"true", "false"}) - void thenContinueFilterChainWithoutClientCertHeader(String enabled) { - filterConfig.setForwardingEnabled(enabled); + @Test + void thenContinueFilterChainWithoutClientCertHeader() { GatewayFilter filter = filterFactory.apply(filterConfig); Mono result = filter.filter(exchange, chain); result.block(); @@ -229,10 +203,8 @@ void setup() { when(request.getSslInfo()).thenReturn(null); } - @ParameterizedTest - @ValueSource(strings = {"true", "false"}) - void thenContinueFilterChainWithoutClientCertHeader(String enabled) { - filterConfig.setForwardingEnabled(enabled); + @Test + void thenContinueFilterChainWithoutClientCertHeader() { GatewayFilter filter = filterFactory.apply(filterConfig); Mono result = filter.filter(exchange, chain); result.block(); diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/X509FilterFactoryTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/X509FilterFactoryTest.java index e2a4717374..6d1f18b0a8 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/X509FilterFactoryTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/X509FilterFactoryTest.java @@ -32,12 +32,15 @@ import java.security.Principal; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.zowe.apiml.constants.ApimlConstants.HTTP_CLIENT_USE_CLIENT_CERTIFICATE; class X509FilterFactoryTest { public static final String ALL_HEADERS = "X-Certificate-Public,X-Certificate-DistinguishedName,X-Certificate-CommonName"; @@ -74,10 +77,12 @@ void setup() { when(sslInfo.getPeerCertificates()).thenReturn(x509Certificates); - when(certificate.getSubjectDN()).thenReturn(new X500Principal("CN=user, OU=JavaSoft, O=Sun Microsystems, C=US")); when(exchange.mutate()).thenReturn(exchangeBuilder); + Map attributes = new HashMap<>(); + when(exchange.getAttributes()).thenReturn(attributes); + when(chain.filter(exchange)).thenReturn(Mono.empty()); } @@ -88,6 +93,7 @@ void givenCertificateInRequest_thenPopulateHeaders() throws Exception { Mono result = filter.filter(exchange, chain); result.block(); assertEquals("user", exchange.getRequest().getHeaders().get("X-Certificate-CommonName").get(0)); + assertEquals(Boolean.TRUE, exchange.getAttributes().get(HTTP_CLIENT_USE_CLIENT_CERTIFICATE)); } @Test @@ -98,6 +104,7 @@ void givenCertificateWithIncorrectEncoding_thenProvideInfoInHeader() throws Exce Mono result = filter.filter(exchange, chain); result.block(); assertEquals("Invalid client certificate in request. Error message: incorrect encoding", exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER).get(0)); + assertNull(exchange.getAttribute(HTTP_CLIENT_USE_CLIENT_CERTIFICATE)); } @Test @@ -115,6 +122,7 @@ void givenNoCertificateInRequest_thenContinueFilterChainWithUntouchedHeaders() { result.block(); assertEquals("ZWEAG167E No client certificate provided in the request", exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER).get(0)); assertEquals("ZWEAG167E No client certificate provided in the request", responseHeaders.get(ApimlConstants.AUTH_FAIL_HEADER).get(0)); + assertNull(exchange.getAttribute(HTTP_CLIENT_USE_CLIENT_CERTIFICATE)); } @Test @@ -179,9 +187,8 @@ public ServerHttpRequest build() { } } - ; - public class ServerWebExchangeBuilderMock implements ServerWebExchange.Builder { + ServerHttpRequest request; @Override @@ -210,8 +217,7 @@ public ServerWebExchange build() { when(exchange.getRequest()).thenReturn(request); return exchange; } - } - ; + } } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java index 2c645b21e6..6ad92c589b 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java @@ -11,7 +11,6 @@ package org.zowe.apiml.cloudgatewayservice.service; -import io.netty.handler.ssl.SslContext; import org.apache.groovy.util.Maps; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -21,7 +20,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.cloud.client.ServiceInstance; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.reactive.function.client.ClientRequest; import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.ExchangeFunction; @@ -30,27 +28,17 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import java.util.AbstractMap; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; -import static org.zowe.apiml.cloudgatewayservice.service.WebClientHelperTest.KEYSTORE_PATH; -import static org.zowe.apiml.cloudgatewayservice.service.WebClientHelperTest.PASSWORD; +import static org.mockito.Mockito.*; import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; @ExtendWith(MockitoExtension.class) class GatewayIndexServiceTest { + private GatewayIndexService gatewayIndexService; private final ParameterizedTypeReference> serviceInfoType = new ParameterizedTypeReference>() { }; @@ -67,7 +55,6 @@ class GatewayIndexServiceTest { @BeforeEach void setUp() { - lenient().when(eurekaInstance.getMetadata()).thenReturn(Maps.of(APIML_ID, "testApimlIdA")); lenient().when(eurekaInstance.getInstanceId()).thenReturn("testInstanceIdA"); @@ -82,7 +69,7 @@ void setUp() { serviceInfoB.getApiml().setApiInfo(Collections.singletonList(sysviewApiInfo)); webClient = spy(WebClient.builder().exchangeFunction(exchangeFunction).build()); - gatewayIndexService = new GatewayIndexService(webClient, 60, null, null, null); + gatewayIndexService = new GatewayIndexService(webClient, 60); } @Nested @@ -185,48 +172,4 @@ void shouldReturnEmptyMapForNotExistingApimlId() { } } - @Nested - class WhenUsingCustomClientKey { - - @Test - void shouldInitializeCustomSslContext() { - - gatewayIndexService = new GatewayIndexService(webClient, 60, KEYSTORE_PATH, PASSWORD, "PKCS12"); - - SslContext customClientSslContext = (SslContext) ReflectionTestUtils.getField(gatewayIndexService, "customClientSslContext"); - - assertThat(customClientSslContext).isNotNull(); - } - - @Test - void shouldNotUseDefaultWebClientWhenCustomContextIdProvided() { - gatewayIndexService = new GatewayIndexService(webClient, 60, KEYSTORE_PATH, PASSWORD, "PKCS12"); - - StepVerifier.create(gatewayIndexService.indexGatewayServices(eurekaInstance)) - .verifyComplete(); - - verifyNoInteractions(webClient); - } - - @Test - void shouldSkipCustomSslContextCreationIfPasswordNotDefined() { - - gatewayIndexService = new GatewayIndexService(webClient, 60, KEYSTORE_PATH, null, null); - - SslContext customClientSslContext = (SslContext) ReflectionTestUtils.getField(gatewayIndexService, "customClientSslContext"); - - assertThat(customClientSslContext).isNull(); - } - - @Test - void shouldUseDefaultWebClientWhenCustomSslContextIsNotProvided() { - gatewayIndexService = new GatewayIndexService(webClient, 60, null, null, null); - - StepVerifier.create(gatewayIndexService.indexGatewayServices(eurekaInstance)) - .verifyComplete(); - - verify(webClient).mutate(); - } - - } } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java index f4f78235af..cb424e92dc 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java @@ -20,6 +20,7 @@ import org.springframework.cloud.gateway.filter.FilterDefinition; import org.springframework.cloud.gateway.route.RouteDefinition; import org.springframework.context.ApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import org.zowe.apiml.auth.Authentication; @@ -38,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.SERVICE_SUPPORTING_CLIENT_CERT_FORWARDING; class RouteLocatorTest { @@ -206,6 +208,25 @@ void givenNonGatewayService_whenGetRoutedService_thenReturnRoutingFromMetadata() assertEquals(2, rs.size()); } + @Nested + class JoinLists { + + @Test + void givenSecondOneEmpty_whenJoin_thenReturnFirstInstance() { + List a = Collections.singletonList(1); + List b = Collections.emptyList(); + assertSame(a, RouteLocator.join(a, b)); + } + + @Test + void givenNonEmptyLists_whenJoin_thenCreateNewOneWithAllValues() { + List a = Arrays.asList(1, 2, 3); + List b = Arrays.asList(3, 4, 5); + assertIterableEquals(Arrays.asList(1, 2, 3, 3, 4, 5), RouteLocator.join(a, b)); + } + + } + } @Nested @@ -238,6 +259,78 @@ void givenRouteLocator_whenGetRouteDefinitions_thenGenerateAll() { } } + @Nested + class PostRoutingFilterDefinition { + + private final List COMMON_FILTERS = Collections.singletonList(mock(FilterDefinition.class)); + private final RouteLocator routeLocator = new RouteLocator(null, null, null, COMMON_FILTERS, Collections.emptyList(), null); + + private ServiceInstance createServiceInstance(Boolean forwardingEnabled) { + Map metadata = new HashMap<>(); + if (forwardingEnabled != null) { + metadata.put(SERVICE_SUPPORTING_CLIENT_CERT_FORWARDING, String.valueOf(forwardingEnabled)); + } + ServiceInstance serviceInstance = mock(ServiceInstance.class); + doReturn(metadata).when(serviceInstance).getMetadata(); + return serviceInstance; + } + + @Nested + class EnabledForwarding { + + @BeforeEach + void enableForwarding() { + ReflectionTestUtils.setField(routeLocator, "forwardingClientCertEnabled", true); + } + + @Test + void givenServiceAllowingCertForwarding_whenGetPostRoutingFilters_thenAddClientCertFilterFactory() { + ServiceInstance serviceInstance = createServiceInstance(Boolean.TRUE); + + List filterDefinitions = routeLocator.getPostRoutingFilters(serviceInstance); + assertEquals(2, filterDefinitions.size()); + assertEquals("ClientCertFilterFactory", filterDefinitions.get(1).getName()); + } + + @Test + void givenServiceNotAllowingCertForwarding_whenGetPostRoutingFilters_thenReturnJustCommon() { + ServiceInstance serviceInstance = createServiceInstance(Boolean.FALSE); + + List filterDefinitions = routeLocator.getPostRoutingFilters(serviceInstance); + assertSame(COMMON_FILTERS, filterDefinitions); + } + + + @Test + void givenServiceWithoutCertForwardingConfig_whenGetPostRoutingFilters_thenReturnJustCommon() { + ServiceInstance serviceInstance = createServiceInstance(null); + + List filterDefinitions = routeLocator.getPostRoutingFilters(serviceInstance); + assertSame(COMMON_FILTERS, filterDefinitions); + } + + } + + @Nested + class DisabledForwarding { + + @BeforeEach + void disableForwarding() { + ReflectionTestUtils.setField(routeLocator, "forwardingClientCertEnabled", false); + } + + @Test + void givenAnyService_whenGetPostRoutingFilters_thenReturnJustCommon() { + ServiceInstance serviceInstance = createServiceInstance(Boolean.TRUE); + + List filterDefinitions = routeLocator.getPostRoutingFilters(serviceInstance); + assertSame(COMMON_FILTERS, filterDefinitions); + } + + } + + } + } } diff --git a/common-service-core/src/main/java/org/zowe/apiml/constants/ApimlConstants.java b/common-service-core/src/main/java/org/zowe/apiml/constants/ApimlConstants.java index 36e7fec2b5..f51ad26eb0 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/constants/ApimlConstants.java +++ b/common-service-core/src/main/java/org/zowe/apiml/constants/ApimlConstants.java @@ -24,4 +24,6 @@ private ApimlConstants() { public static final String PAT_COOKIE_AUTH_NAME = "personalAccessToken"; public static final String PAT_HEADER_NAME = "PRIVATE-TOKEN"; public static final String AUTH_FAIL_HEADER = "X-Zowe-Auth-Failure"; + public static final String HTTP_CLIENT_USE_CLIENT_CERTIFICATE = "apiml.useClientCert"; + } diff --git a/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java b/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java index 22c456b3c5..486327cf4e 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java +++ b/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java @@ -32,6 +32,7 @@ private EurekaMetadataDefinition() { public static final String SERVICE_TITLE = "apiml.service.title"; public static final String SERVICE_DESCRIPTION = "apiml.service.description"; public static final String SERVICE_EXTERNAL_URL = "apiml.service.externalUrl"; + public static final String SERVICE_SUPPORTING_CLIENT_CERT_FORWARDING = "apiml.service.supportClientCertForwarding"; public static final String APIML_ID = "apiml.service.apimlId"; public static final String API_INFO = "apiml.apiInfo"; diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/GatewayConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/GatewayConfig.java index 66f2b306b4..ff6e682027 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/GatewayConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/GatewayConfig.java @@ -13,7 +13,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.http.impl.client.CloseableHttpClient; -import org.springframework.beans.factory.annotation.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.commons.util.IdUtils; import org.springframework.cloud.commons.util.InetUtils; import org.springframework.cloud.netflix.eureka.EurekaInstanceConfigBean; @@ -34,8 +36,7 @@ import java.util.Map; -import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; -import static org.zowe.apiml.constants.EurekaMetadataDefinition.SERVICE_EXTERNAL_URL; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.*; @Configuration @RequiredArgsConstructor diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 5d6dc8a7c5..d7ec93953e 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -258,6 +258,7 @@ eureka: service: title: API Gateway description: API Gateway service to route requests to services registered in the API Mediation Layer and provides an API for mainframe security. + supportClientCertForwarding: true authentication: sso: true diff --git a/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java b/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java index 05b7ecd9a9..2aefac2acc 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java @@ -18,6 +18,7 @@ import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.http.HttpHeaders; +import org.springframework.util.CollectionUtils; import org.zowe.apiml.util.SecurityUtils; import org.zowe.apiml.util.TestWithStartedInstances; import org.zowe.apiml.util.categories.DiscoverableClientDependentTest; @@ -117,14 +118,12 @@ private static Stream validToBeTransformed() { Arguments.of("z/OSMF auth scheme", ZOSMF_REQUEST, (Consumer) response -> { assertNotNull(response.jsonPath().getString("cookies.jwtToken")); assertNull(response.jsonPath().getString("headers.authorization")); - //TODO: uncomment once http client handle client certs propertly - //assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); + assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); }), Arguments.of("passticket auth scheme", REQUEST_INFO_ENDPOINT, (Consumer) response -> { assertNotNull(response.jsonPath().getString("headers.authorization")); assertTrue(response.jsonPath().getString("headers.authorization").startsWith("Basic ")); - //TODO: uncomment once http client handle client certs propertly - //assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); + assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); }) ); } @@ -135,8 +134,7 @@ private static Stream noCredentials() { assertEquals(200, response.getStatusCode()); assertNull(response.jsonPath().getString("cookies.jwtToken")); assertNull(response.jsonPath().getString("headers.authorization")); - //TODO: uncomment once http client handle client certs propertly - //assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); + assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); }; return Stream.of( @@ -161,7 +159,7 @@ void setCredentials() { @ParameterizedTest(name = "givenValidRequest_thenCredentialsAreTransformed {0} [{index}]") @MethodSource("org.zowe.apiml.functional.gateway.CloudGatewayRoutingTest#validToBeTransformed") - void givenValidRequest_thenCredentialsAreTransformed(String title, String basePath, Consumer assertions) { + void givenValidRequest_thenCredentialsAreTransformed(String title, String basePath, Consumer assertions) { Response response = given() .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) .when() @@ -172,7 +170,7 @@ void givenValidRequest_thenCredentialsAreTransformed(String title, String ba @ParameterizedTest(name = "givenNoCredentials_thenNoCredentialsAreProvided {0} [{index}]") @MethodSource("org.zowe.apiml.functional.gateway.CloudGatewayRoutingTest#noCredentials") - void givenNoCredentials_thenNoCredentialsAreProvided(String title, String basePath, Consumer assertions) { + void givenNoCredentials_thenNoCredentialsAreProvided(String title, String basePath, Consumer assertions) { Response response = given().when() .get(String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), basePath)); assertions.accept(response); @@ -181,7 +179,7 @@ void givenNoCredentials_thenNoCredentialsAreProvided(String title, String ba @ParameterizedTest(name = "givenInvalidCredentials_thenNoCredentialsAreProvided {0} [{index}]") @MethodSource("org.zowe.apiml.functional.gateway.CloudGatewayRoutingTest#noCredentials") - void givenInvalidCredentials_thenNoCredentialsAreProvided(String title, String basePath, Consumer assertions) { + void givenInvalidCredentials_thenNoCredentialsAreProvided(String title, String basePath, Consumer assertions) { Response response = given().header(HttpHeaders.AUTHORIZATION, "Bearer invalidToken") .when() .get(String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), basePath));