From 0e8ce22d2905542a25a2314e6058cecae63ca24d Mon Sep 17 00:00:00 2001 From: venkatesh010 Date: Sat, 18 May 2019 21:50:13 +0530 Subject: [PATCH] Adds KeyStore support in HttpClient fixes gh-852 --- .../config/GatewayAutoConfiguration.java | 20 +++- .../gateway/config/HttpClientProperties.java | 107 ++++++++++++++++++ .../filter/WebClientWriteResponseFilter.java | 13 +-- .../gateway/test/BaseWebClientTests.java | 2 +- .../test/ssl/ClientCertAuthSSLTests.java | 103 +++++++++++++++++ .../gateway/test/ssl/SingleCertSSLTests.java | 2 +- .../resources/application-client-auth-ssl.yml | 38 +++++++ ...g-keystore-with-different-key-password.jks | Bin 0 -> 2263 bytes .../src/test/resources/scg-truststore.jks | Bin 0 -> 3208 bytes ...single-cert-for-different-key-password.pem | Bin 0 -> 913 bytes 10 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/ssl/ClientCertAuthSSLTests.java create mode 100644 spring-cloud-gateway-core/src/test/resources/application-client-auth-ssl.yml create mode 100644 spring-cloud-gateway-core/src/test/resources/scg-keystore-with-different-key-password.jks create mode 100644 spring-cloud-gateway-core/src/test/resources/scg-truststore.jks create mode 100644 spring-cloud-gateway-core/src/test/resources/single-cert-for-different-key-password.pem 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 0000000000000000000000000000000000000000..9732690dd2af4ed160ce3d9a9c91affc4ebdd2d6 GIT binary patch literal 2263 zcmchYc{J3G8pr21#tg=qU3O#a^|OpEjgl=yq?asZ%@&g@d&Ce9lYPxLB3UvSJGZE@ zC&cs;B_y(D$!kUkZRVhDkq~2 zm6RzbLyNa4_eX@qusCA;$e@{a?JoJXnUBQ!A=I(r9T{ysllBBBEg^feOfyy4(0{uz z!>1#yDQUsYs32UoRw`7U52EO*{(W33j_h24)^z=A!DKG5BQ|9u+DSelM;o!e*`Q$- zuBA;P=5Mmkriq02X|F^se(6A3r}vBwFMu|=>XT8ebI6DKA_bJ9$EVHdWmsi0$VQT2XvsuN!?qRJ5j|8pMOlcgdj4O#-R>B+c@{b35-cN(aD^f;o1^M@=k z#OBbb>>8(~#%0L`y6IJ^2C{gYEmx<};wrb{v#8pY__kQa(9`KF>iTn;sC(AWaDQIP zFuYy1lTVrz(sR=Jl$Rq@9OZ_(B9wH_&FfUE+_UX%!}-zF7vF3$A@nFreM__sS}ym; zk`Va)EaDqrGOet=`A|vk)KcYeSbqI```#HYUpV+<1jE?X>G65LL`bfCEy^0ZgeR_! zlH9pn7fv4Lna;bPa4Ug_`{i8p23AH#4u-q@Kn5a`TkzSN6xhC+8EuN z8iCsMkzs}+9K4&^qXd@hX&oH+0=?gYv{`k=+UIg6=6wL`*6>wrVmOf zP+R@1S;8hy$V{Sg^Oyx=ENJZW<1NMoq2&vxF8cYGK1IzZCzdGSM!HQXa(;3BN2^eb z#-VgY&e8p<#)Bu+GCPK+)^$5RHJi75x0rnHg|AiRx|6Jsb%T!bZ>~AMYSPW4Hofq! z^yIXdMpoLJ_tI3cJ-d;x9c-a7i>ixVQ&D7aRB~ZRY~4+EPWd`wB?l`(QuVSZB6FgD z*3o2qD!6jJ(jjbs4)2ASMGQ3$BAvJ1Cd8QeckmWfKX%t7MP;NKL71q5{Ser+pko&e z0UFy$YGJvMU>Mu-Dd6$_`dFm(=vV^N3qKqGyj1S8KU>Q7;J&pqv_2n0M%9HBx~kDLO9HoJaen4(TtaQ!T$Lqg(u5#$p#WWxmzB zwC;um7tI|utkuRj;_2V}ng|go=q6tmCymi)G0edIG*^F>@+wc3Jpu`f_A>NXm8i`9 za#CQb+g2TyuO~Pv11)p@B*ySbZ`H{UED*l4dznyh5Z4hGHR#a4DxIi~!)E1`Mp9TG zsEcJbq|EqITb6e`Ys=2tB;+n>UKIuHm`28d^R0OFy+ zL?$Qzg~Hq|WBNc2IICiUX#$M{0FcZO@Rlm~ab+@Hg~53dR|EV4aG?SJB`m)Y#BYS{ zH-h|)F#V5!;({EiI-sVSmYN!<3Tiq0T6NSg|5^Xf*Wdx^zuR&AEtv2C7X-uuY*0J^ zK(cCA=O5|$pMwj0q+UrZv1#nJn6qk8wn+1%n7?hSmP1aA3pCqU({PB*Bk+@D2CFPaDWzQOS=X*V%?GRjjm|lvOet6|<9* zIuuC?CW>st4GtD=l))8?>{2||t4Vuh_9!95F4Dah&ZhF=jP#g*QqZ-)maFOf=gYYv zO1u>hsS*CZ)SIK19P8e{I+)BX^ibC4qEH2p@%d)GRia-7%=1ec+tK4Mm%6!`b)ZZD z1Sr=6F(BV@Ld1Ar93T?UdJE_7ddnLZbO#jsH|2y0!1y*lipau4gszoZ8SnIsZLs^| z`+xl1+T)}hTMIyVUC!JqZoE9}(fj;$*#oUVy7u|wYo=a!JndHHqpb>Dlx0q>ReoD^ z$d~tF%!tIqU0^`84 z!xOoCAYPAqHrwpT%%3nAsw7P=DYm~`D50A5M@52NuW5$x&zzc}^Q}?ZA)wg!(S2bCoId!_sA@(9L(Fv7MIUa)A~M*ksW9qxYteQi^+MKL9Gjw h-d^6LN>IJ9l5COv#O4&a6^wwFuG=^G3CmY+{{@f4>5TvY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..94ba08b9c2643a432fa7f91fa50ffc2d304532d3 GIT binary patch literal 3208 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 K&TBK-Q~&@xVONR( literal 0 HcmV?d00001