diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index e2a23713..7e5fa9f5 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -23,6 +23,8 @@ import com.netflix.hystrix.HystrixObservableCommand; import io.netty.channel.ChannelOption; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Flux; import reactor.netty.http.client.HttpClient; import reactor.netty.resources.ConnectionProvider; @@ -522,6 +524,8 @@ public class GatewayAutoConfiguration { @ConditionalOnClass(HttpClient.class) protected static class NettyConfiguration { + protected final Log logger = LogFactory.getLog(getClass()); + @Bean @ConditionalOnProperty(name = "spring.cloud.gateway.httpserver.wiretap") public NettyWebServerFactoryCustomizer nettyServerWiretapCustomizer( @@ -588,7 +592,8 @@ public class GatewayAutoConfiguration { }); HttpClientProperties.Ssl ssl = properties.getSsl(); - if (ssl.getTrustedX509CertificatesForTrustManager().length > 0 + if ((ssl.getKeyStore() != null && ssl.getKeyStore().length() > 0) + || ssl.getTrustedX509CertificatesForTrustManager().length > 0 || ssl.isUseInsecureTrustManager()) { httpClient = httpClient.secure(sslContextSpec -> { // configure ssl @@ -597,13 +602,22 @@ public class GatewayAutoConfiguration { X509Certificate[] trustedX509Certificates = ssl .getTrustedX509CertificatesForTrustManager(); if (trustedX509Certificates.length > 0) { - sslContextBuilder.trustManager(trustedX509Certificates); + sslContextBuilder = sslContextBuilder + .trustManager(trustedX509Certificates); } else if (ssl.isUseInsecureTrustManager()) { - sslContextBuilder + sslContextBuilder = sslContextBuilder .trustManager(InsecureTrustManagerFactory.INSTANCE); } + try { + sslContextBuilder = sslContextBuilder + .keyManager(ssl.getKeyManagerFactory()); + } + catch (Exception e) { + logger.error(e); + } + sslContextSpec.sslContext(sslContextBuilder) .defaultConfiguration(ssl.getDefaultConfigurationType()) .handshakeTimeout(ssl.getHandshakeTimeout()) diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java index e2d0c475..4c4333c2 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java @@ -18,6 +18,9 @@ package org.springframework.cloud.gateway.config; import java.io.IOException; import java.net.URL; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchProviderException; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; @@ -27,6 +30,8 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import javax.net.ssl.KeyManagerFactory; + import reactor.netty.resources.ConnectionProvider; import reactor.netty.tcp.SslProvider; @@ -305,6 +310,61 @@ public class HttpClientProperties { /** The default ssl configuration type. Defaults to TCP. */ private SslProvider.DefaultConfigurationType defaultConfigurationType = SslProvider.DefaultConfigurationType.TCP; + /** Keystore path for Netty HttpClient. */ + private String keyStore; + + /** Keystore type for Netty HttpClient, default is JKS. */ + private String keyStoreType = "JKS"; + + /** Keystore provider for Netty HttpClient, optional field. */ + private String keyStoreProvider; + + /** Keystore password. */ + private String keyStorePassword; + + /** Key password, default is same as keyStorePassword. */ + private String keyPassword; + + public String getKeyStorePassword() { + return keyStorePassword; + } + + public void setKeyStorePassword(String keyStorePassword) { + this.keyStorePassword = keyStorePassword; + } + + public String getKeyStoreType() { + return keyStoreType; + } + + public void setKeyStoreType(String keyStoreType) { + this.keyStoreType = keyStoreType; + } + + public String getKeyStoreProvider() { + return keyStoreProvider; + } + + public void setKeyStoreProvider(String keyStoreProvider) { + this.keyStoreProvider = keyStoreProvider; + } + + public String getKeyStore() { + return keyStore; + } + + public void setKeyStore(String keyStore) { + this.keyStore = keyStore; + } + + public String getKeyPassword() { + return keyPassword; + } + + public void setKeyPassword(String keyPassword) { + this.keyPassword = keyPassword; + } + public List getTrustedX509Certificates() { return trustedX509Certificates; } @@ -338,6 +398,53 @@ public class HttpClientProperties { } } + public KeyManagerFactory getKeyManagerFactory() { + try { + if (ssl.getKeyStore() != null && ssl.getKeyStore().length() > 0) { + KeyManagerFactory keyManagerFactory = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()); + char[] keyPassword = ssl.getKeyPassword() != null + ? ssl.getKeyPassword().toCharArray() : null; + + if (keyPassword == null && ssl.getKeyStorePassword() != null) { + keyPassword = ssl.getKeyStorePassword().toCharArray(); + } + + keyManagerFactory.init(this.createKeyStore(), keyPassword); + + return keyManagerFactory; + } + + return null; + } + catch (Exception e) { + throw new IllegalStateException(e); + } + } + + public KeyStore createKeyStore() { + try { + KeyStore store = ssl.getKeyStoreProvider() != null ? KeyStore.getInstance(ssl.getKeyStoreType(), + ssl.getKeyStoreProvider()) + : KeyStore.getInstance(ssl.getKeyStoreType()); + try { + URL url = ResourceUtils.getURL(ssl.getKeyStore()); + store.load(url.openStream(), ssl.getKeyStorePassword() != null + ? ssl.getKeyStorePassword().toCharArray() : null); + } + catch (Exception e) { + throw new WebServerException( + "Could not load key store ' " + ssl.getKeyStore() + "'", e); + } + + return store; + } + catch (KeyStoreException | NoSuchProviderException e) { + throw new WebServerException( + "Could not load KeyStore for given type and provider", e); + } + } + // TODO: support configuration of other trust manager factories public boolean isUseInsecureTrustManager() { diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/WebClientWriteResponseFilter.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/WebClientWriteResponseFilter.java index 63ed4248..aa3dc960 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/WebClientWriteResponseFilter.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/WebClientWriteResponseFilter.java @@ -49,20 +49,18 @@ public class WebClientWriteResponseFilter implements GlobalFilter, Ordered { public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { // NOTICE: nothing in "pre" filter stage as CLIENT_RESPONSE_ATTR is not added // until the WebHandler is run - return chain.filter(exchange).doOnError(throwable -> cleanup(exchange)) + return chain.filter(exchange) + .doOnError(throwable -> cleanup(exchange)) .then(Mono.defer(() -> { - ClientResponse clientResponse = exchange - .getAttribute(CLIENT_RESPONSE_ATTR); + ClientResponse clientResponse = exchange.getAttribute(CLIENT_RESPONSE_ATTR); if (clientResponse == null) { return Mono.empty(); } log.trace("WebClientWriteResponseFilter start"); ServerHttpResponse response = exchange.getResponse(); - return response - .writeWith( - clientResponse.body(BodyExtractors.toDataBuffers())) - // .log("webClient response") + return response.writeWith(clientResponse.body(BodyExtractors.toDataBuffers())) + //.log("webClient response") .doOnCancel(() -> cleanup(exchange)); })); } @@ -73,5 +71,4 @@ public class WebClientWriteResponseFilter implements GlobalFilter, Ordered { clientResponse.bodyToMono(Void.class).subscribe(); } } - } diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java index ff944508..6dec855f 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java @@ -65,7 +65,7 @@ public class BaseWebClientTests { protected String baseUri; @Before - public void setup() { + public void setup() throws Exception { setup(new ReactorClientHttpConnector(), "http://localhost:" + port); } diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/ClientCertAuthSSLTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/ClientCertAuthSSLTests.java new file mode 100644 index 00000000..0a612321 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/ClientCertAuthSSLTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2013-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.test.ssl; + +import java.net.URL; +import java.security.KeyStore; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLException; + +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import reactor.netty.http.client.HttpClient; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.WebServerException; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.ResourceUtils; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +@ActiveProfiles("client-auth-ssl") +public class ClientCertAuthSSLTests extends SingleCertSSLTests { + + @Value("${spring.cloud.gateway.httpclient.ssl.key-store}") + private String keyStore; + + @Value("${spring.cloud.gateway.httpclient.ssl.key-store-password}") + private String keyStorePassword; + + @Value("${spring.cloud.gateway.httpclient.ssl.key-password}") + private String keyPassword; + + @Before + public void setup() throws Exception { + KeyStore store = KeyStore.getInstance("JKS"); + + try { + URL url = ResourceUtils.getURL(keyStore); + store.load(url.openStream(), + keyStorePassword != null ? keyStorePassword.toCharArray() : null); + } + catch (Exception e) { + throw new WebServerException("Could not load key store ' " + keyStore + "'", + e); + } + + KeyManagerFactory keyManagerFactory = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()); + char[] keyPasswordCharArray = keyPassword != null ? keyPassword.toCharArray() + : null; + + if (keyPasswordCharArray == null && keyStorePassword != null) { + keyPasswordCharArray = keyStorePassword.toCharArray(); + } + + keyManagerFactory.init(store, keyPasswordCharArray); + + try { + SslContext sslContext = SslContextBuilder.forClient() + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .keyManager(keyManagerFactory).build(); + HttpClient httpClient = HttpClient.create() + .secure(ssl -> ssl.sslContext(sslContext)); + setup(new ReactorClientHttpConnector(httpClient), + "https://localhost:" + port); + } + catch (SSLException e) { + throw new RuntimeException(e); + } + } + + @Test + public void testSslTrust() { + testClient.get().uri("/ssltrust").exchange().expectStatus().is2xxSuccessful(); + } + +} diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/SingleCertSSLTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/SingleCertSSLTests.java index a75a0d3f..1b9e7170 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/SingleCertSSLTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/SingleCertSSLTests.java @@ -48,7 +48,7 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen public class SingleCertSSLTests extends BaseWebClientTests { @Before - public void setup() { + public void setup() throws Exception { try { SslContext sslContext = SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE).build(); diff --git a/spring-cloud-gateway-core/src/test/resources/application-client-auth-ssl.yml b/spring-cloud-gateway-core/src/test/resources/application-client-auth-ssl.yml new file mode 100644 index 00000000..88d1b138 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/resources/application-client-auth-ssl.yml @@ -0,0 +1,38 @@ +test: + uri: lb:https://testservice + +server: + ssl: + enabled: true + key-alias: scg + key-store-password: scg1234 + key-password: keyscg1234 + key-store: classpath:scg-keystore-with-different-key-password.jks + trust-store: classpath:scg-truststore.jks + trust-store-password: scg1234 + trust-store-type: JKS + key-store-type: JKS + client-auth: Need +spring: + cloud: + gateway: + httpclient: + ssl: + key-store: classpath:scg-keystore-with-different-key-password.jks + key-password: keyscg1234 + key-store-password: scg1234 + key-store-type: JKS + trustedX509Certificates: + - src/test/resources/single-cert-for-different-key-password.pem + default-filters: + - PrefixPath=/httpbin + routes: + - id: default_path_to_httpbin + uri: ${test.uri} + order: 10000 + predicates: + - name: Path + args: + pattern: /** + + diff --git a/spring-cloud-gateway-core/src/test/resources/scg-keystore-with-different-key-password.jks b/spring-cloud-gateway-core/src/test/resources/scg-keystore-with-different-key-password.jks new file mode 100644 index 00000000..9732690d Binary files /dev/null and b/spring-cloud-gateway-core/src/test/resources/scg-keystore-with-different-key-password.jks differ diff --git a/spring-cloud-gateway-core/src/test/resources/scg-truststore.jks b/spring-cloud-gateway-core/src/test/resources/scg-truststore.jks new file mode 100644 index 00000000..94ba08b9 Binary files /dev/null and b/spring-cloud-gateway-core/src/test/resources/scg-truststore.jks differ diff --git a/spring-cloud-gateway-core/src/test/resources/single-cert-for-different-key-password.pem b/spring-cloud-gateway-core/src/test/resources/single-cert-for-different-key-password.pem new file mode 100644 index 00000000..2f5f929f Binary files /dev/null and b/spring-cloud-gateway-core/src/test/resources/single-cert-for-different-key-password.pem differ