diff --git a/docs/src/main/asciidoc/spring-cloud-netflix.adoc b/docs/src/main/asciidoc/spring-cloud-netflix.adoc index cbddefcd8..8047e95e2 100755 --- a/docs/src/main/asciidoc/spring-cloud-netflix.adoc +++ b/docs/src/main/asciidoc/spring-cloud-netflix.adoc @@ -85,6 +85,26 @@ To disable the Eureka Discovery Client, you can set `eureka.client.enabled` to ` HTTP basic authentication is automatically added to your eureka client if one of the `eureka.client.serviceUrl.defaultZone` URLs has credentials embedded in it (curl style, as follows: `https://user:password@localhost:8761/eureka`). For more complex needs, you can create a `@Bean` of type `DiscoveryClientOptionalArgs` and inject `ClientFilter` instances into it, all of which is applied to the calls from the client to the server. +When Eureka server requires client side certificate for authentication, the client side certificate and trust store can be configured via properties, as shown in following example: + +.application.yml +[source,yaml] +---- +eureka: + client: + tls: + enabled: true + key-store: + key-store-type: PKCS12 + key-store-password: + key-password: + trust-store: + trust-store-type: PKCS12 + trust-store-password: +---- + +The `eureka.client.tls.enabled` needs to be true to enable Eureka client side TLS. When `eureka.client.tls.trust-store` is omitted, a JVM default trust store is used. The default value for `eureka.client.tls.key-store-type` and `eureka.client.tls.trust-store-type` is PKCS12. When password properties are omitted, empty password is assumed. + NOTE: Because of a limitation in Eureka, it is not possible to support per-server basic auth credentials, so only the first set that are found is used. === Status Page and Health Indicator diff --git a/pom.xml b/pom.xml index c003c9266..ab6ae498e 100644 --- a/pom.xml +++ b/pom.xml @@ -156,6 +156,7 @@ spring-cloud-netflix-eureka-server spring-cloud-starter-netflix-eureka-client spring-cloud-starter-netflix-eureka-server + spring-cloud-netflix-eureka-client-tls-tests docs diff --git a/spring-cloud-netflix-eureka-client-tls-tests/pom.xml b/spring-cloud-netflix-eureka-client-tls-tests/pom.xml new file mode 100644 index 000000000..ba93d1a02 --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + org.springframework.cloud + spring-cloud-netflix + 3.0.0-SNAPSHOT + .. + + spring-cloud-netflix-eureka-client-tls-tests + jar + Spring Cloud Netflix Eureka Client TLS Tests + Spring Cloud Netflix Eureka Client TLS Tests + + + + org.springframework.cloud + spring-cloud-netflix-eureka-client + + + org.springframework.cloud + spring-cloud-netflix-eureka-server + + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-logging + true + + + org.springframework.cloud + spring-cloud-commons + + + org.springframework.cloud + spring-cloud-context + + + org.springframework + spring-web + + + com.fasterxml.jackson.core + jackson-annotations + + + org.springframework.retry + spring-retry + true + + + org.springframework.boot + spring-boot-starter-actuator + true + + + org.springframework.boot + spring-boot-starter-aop + true + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.junit.vintage + junit-vintage-engine + test + + + org.bouncycastle + bcpkix-jdk15on + 1.64 + test + + + javax.xml + jaxb-impl + 2.1 + test + + + diff --git a/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/AppRunner.java b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/AppRunner.java new file mode 100644 index 000000000..3e48433f9 --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/AppRunner.java @@ -0,0 +1,125 @@ +/* + * Copyright 2018-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.netflix.eureka; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.util.SocketUtils; + +public class AppRunner implements AutoCloseable { + + private Class appClass; + + private Map props; + + private ConfigurableApplicationContext app; + + public AppRunner(Class appClass) { + this.appClass = appClass; + props = new LinkedHashMap<>(); + } + + public void property(String key, String value) { + props.put(key, value); + } + + public void start() { + if (app == null) { + SpringApplicationBuilder builder = new SpringApplicationBuilder(appClass); + builder.properties("spring.jmx.enabled=false"); + builder.properties(String.format("server.port=%d", availabeTcpPort())); + builder.properties(props()); + + app = builder.build().run(); + } + } + + private int availabeTcpPort() { + return SocketUtils.findAvailableTcpPort(); + } + + private String[] props() { + List result = new ArrayList<>(); + + for (String key : props.keySet()) { + String value = props.get(key); + result.add(String.format("%s=%s", key, value)); + } + + return result.toArray(new String[0]); + } + + public void stop() { + if (app != null) { + app.stop(); + app = null; + } + } + + public ConfigurableApplicationContext app() { + return app; + } + + public String getProperty(String key) { + return app.getEnvironment().getProperty(key); + } + + public T getBean(Class type) { + return app.getBean(type); + } + + public ApplicationContext parent() { + return app.getParent(); + } + + public Map getParentBeans(Class type) { + return parent().getBeansOfType(type); + } + + public int port() { + if (app == null) { + throw new RuntimeException("App is not running."); + } + return app.getEnvironment().getProperty("server.port", Integer.class, -1); + } + + public String root() { + if (app == null) { + throw new RuntimeException("App is not running."); + } + + String protocol = tlsEnabled() ? "https" : "http"; + return String.format("%s://localhost:%d/", protocol, port()); + } + + private boolean tlsEnabled() { + return app.getEnvironment().getProperty("server.ssl.enabled", Boolean.class, + false); + } + + @Override + public void close() { + stop(); + } + +} diff --git a/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/BaseCertTest.java b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/BaseCertTest.java new file mode 100644 index 000000000..c910111af --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/BaseCertTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2018-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.netflix.eureka; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.security.KeyStore; + +import org.junit.BeforeClass; + +public abstract class BaseCertTest { + + protected static final String KEY_STORE_PASSWORD = "test-key-store-password"; + + protected static final String KEY_PASSWORD = "test-key-password"; + + protected static final String WRONG_PASSWORD = "test-wrong-password"; + + protected static File caCert; + + protected static File wrongCaCert; + + protected static File serverCert; + + protected static File clientCert; + + protected static File wrongClientCert; + + protected BaseCertTest() { + } + + @BeforeClass + public static void createCertificates() throws Exception { + KeyTool tool = new KeyTool(); + + KeyAndCert ca = tool.createCA("MyCA"); + KeyAndCert server = ca.sign("server"); + KeyAndCert client = ca.sign("client"); + + caCert = saveCert(ca); + serverCert = saveKeyAndCert(server); + clientCert = saveKeyAndCert(client); + + KeyAndCert wrongCa = tool.createCA("WrongCA"); + KeyAndCert wrongClient = wrongCa.sign("client"); + + wrongCaCert = saveCert(wrongCa); + wrongClientCert = saveKeyAndCert(wrongClient); + } + + private static File saveKeyAndCert(KeyAndCert keyCert) throws Exception { + return saveKeyStore(keyCert.subject(), + () -> keyCert.storeKeyAndCert(KEY_PASSWORD)); + } + + private static File saveCert(KeyAndCert keyCert) throws Exception { + return saveKeyStore(keyCert.subject(), () -> keyCert.storeCert()); + } + + private static File saveKeyStore(String prefix, KeyStoreSupplier func) + throws Exception { + File result = File.createTempFile(prefix, ".p12"); + result.deleteOnExit(); + + try (OutputStream output = new FileOutputStream(result)) { + KeyStore store = func.createKeyStore(); + store.store(output, KEY_STORE_PASSWORD.toCharArray()); + } + return result; + } + + interface KeyStoreSupplier { + + KeyStore createKeyStore() throws Exception; + + } + +} diff --git a/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaClientRunner.java b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaClientRunner.java new file mode 100644 index 000000000..6923e8ac2 --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaClientRunner.java @@ -0,0 +1,99 @@ +/* + * Copyright 2018-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.netflix.eureka; + +import java.io.File; +import java.util.function.BooleanSupplier; + +import org.springframework.cloud.client.discovery.DiscoveryClient; + +public class EurekaClientRunner extends AppRunner { + + public EurekaClientRunner(Class appClass, AppRunner server) { + super(appClass); + + property("eureka.client.registerWithEureka", "false"); + property("eureka.client.fetchRegistry", "true"); + property("eureka.client.serviceUrl.defaultZone", server.root() + "eureka/"); + property("eureka.client.refresh.enable", "true"); + } + + public EurekaClientRunner(Class appClass, AppRunner server, String service) { + this(appClass, server); + property("eureka.client.registerWithEureka", "true"); + property("spring.application.name", service); + } + + public void enableTls() { + property("eureka.client.tls.enabled", "true"); + } + + public void disableTls() { + property("eureka.client.tls.enabled", "false"); + } + + public void setKeyStore(File keyStore, String keyStorePassword, String keyPassword) { + property("eureka.client.tls.key-store", pathOf(keyStore)); + property("eureka.client.tls.key-store-password", keyStorePassword); + property("eureka.client.tls.key-password", keyPassword); + } + + public void setKeyStore(File keyStore) { + property("eureka.client.tls.key-store", pathOf(keyStore)); + } + + public void setTrustStore(File trustStore, String password) { + property("eureka.client.tls.trust-store", pathOf(trustStore)); + property("eureka.client.tls.trust-store-password", password); + } + + public void setTrustStore(File trustStore) { + property("eureka.client.tls.trust-store", pathOf(trustStore)); + } + + private String pathOf(File file) { + return String.format("file:%s", file.getAbsolutePath()); + } + + public void waitServiceViaEureka(int seconds) { + assertInSeconds(() -> foundServiceViaEureka(), seconds); + } + + private void assertInSeconds(BooleanSupplier assertion, int seconds) { + long start = System.currentTimeMillis(); + long limit = 1000L * seconds; + long duration = 0; + + do { + if (assertion.getAsBoolean()) { + return; + } + duration = System.currentTimeMillis() - start; + Thread.yield(); + + } + while (duration < limit); + + throw new RuntimeException(); + } + + public boolean foundServiceViaEureka() { + DiscoveryClient discovery = getBean(DiscoveryClient.class); + return !discovery.getServices().isEmpty(); + } + +} diff --git a/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaClientTest.java b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaClientTest.java new file mode 100644 index 000000000..e2d066bf9 --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaClientTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2018-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.netflix.eureka; + +import java.io.File; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EurekaClientTest extends BaseCertTest { + + private static EurekaServerRunner server; + + private static EurekaClientRunner service; + + @BeforeClass + public static void setupAll() { + startEurekaServer(); + startService(); + waitForRegistration(); + } + + @AfterClass + public static void tearDownAll() { + stopService(); + stopEurekaServer(); + } + + private static void startEurekaServer() { + server = new EurekaServerRunner(TestEurekaServer.class); + server.enableTls(); + server.setKeyStore(serverCert, KEY_STORE_PASSWORD, "server", KEY_PASSWORD); + server.setTrustStore(caCert, KEY_STORE_PASSWORD); + + server.start(); + } + + private static void stopEurekaServer() { + server.stop(); + } + + private static void startService() { + service = new EurekaClientRunner(TestApp.class, server, "testservice"); + enableTlsClient(service); + service.start(); + } + + private static void stopService() { + service.stop(); + } + + private static void waitForRegistration() { + try (EurekaClientRunner client = createEurekaClient()) { + enableTlsClient(client); + client.start(); + client.waitServiceViaEureka(60); + } + } + + private static EurekaClientRunner createEurekaClient() { + return new EurekaClientRunner(TestApp.class, server); + } + + private static void enableTlsClient(EurekaClientRunner runner) { + runner.enableTls(); + runner.setKeyStore(clientCert, KEY_STORE_PASSWORD, KEY_PASSWORD); + runner.setTrustStore(caCert, KEY_STORE_PASSWORD); + } + + /** + * Already proved this in waitForRegistration(). Keep this Test to express test + * purpose explicitly. + */ + @Test + public void clientCertCanWork() { + } + + @Test + public void noCertCannotWork() { + try (EurekaClientRunner client = createEurekaClient()) { + client.disableTls(); + client.start(); + assertThat(client.foundServiceViaEureka()).isFalse(); + } + } + + @Test + public void wrongCertCannotWork() { + try (EurekaClientRunner client = createEurekaClient()) { + enableTlsClient(client); + client.setKeyStore(wrongClientCert); + client.start(); + assertThat(client.foundServiceViaEureka()).isFalse(); + } + } + + @Test(expected = BeanCreationException.class) + public void wrongPasswordCauseFailure() { + EurekaClientRunner client = createEurekaClient(); + enableTlsClient(client); + client.setKeyStore(clientCert, WRONG_PASSWORD, WRONG_PASSWORD); + client.start(); + } + + @Test(expected = BeanCreationException.class) + public void nonExistKeyStoreCauseFailure() { + EurekaClientRunner client = createEurekaClient(); + enableTlsClient(client); + client.setKeyStore(new File("nonExistFile")); + client.start(); + } + + @Test + public void wrongTrustStoreCannotWork() { + try (EurekaClientRunner client = createEurekaClient()) { + enableTlsClient(client); + client.setTrustStore(wrongCaCert); + client.start(); + assertThat(client.foundServiceViaEureka()).isFalse(); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + public static class TestApp { + + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EnableEurekaServer + public static class TestEurekaServer { + + } + +} diff --git a/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaServerRunner.java b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaServerRunner.java new file mode 100644 index 000000000..a5fb15763 --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/EurekaServerRunner.java @@ -0,0 +1,56 @@ +/* + * Copyright 2018-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.netflix.eureka; + +import java.io.File; + +public class EurekaServerRunner extends AppRunner { + + public EurekaServerRunner(Class appClass) { + super(appClass); + + property("eureka.client.registerWithEureka", "false"); + property("eureka.client.fetchRegistry", "false"); + property("eureka.server.waitTimeInMsWhenSyncEmpty", "0"); + property("eureka.client.refresh.enable", "true"); + } + + public void enableTls() { + property("server.ssl.enabled", "true"); + property("server.ssl.client-auth", "need"); + } + + public void setKeyStore(File keyStore, String keyStorePassword, String key, + String keyPassword) { + property("server.ssl.key-store", pathOf(keyStore)); + property("server.ssl.key-store-type", "PKCS12"); + property("server.ssl.key-store-password", keyStorePassword); + property("server.ssl.key-alias", key); + property("server.ssl.key-password", keyPassword); + } + + public void setTrustStore(File trustStore, String password) { + property("server.ssl.trust-store", pathOf(trustStore)); + property("server.ssl.trust-store-type", "PKCS12"); + property("server.ssl.trust-store-password", password); + } + + private String pathOf(File file) { + return String.format("file:%s", file.getAbsolutePath()); + } + +} diff --git a/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/KeyAndCert.java b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/KeyAndCert.java new file mode 100644 index 000000000..192febd07 --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/KeyAndCert.java @@ -0,0 +1,94 @@ +/* + * Copyright 2018-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.netflix.eureka; + +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +public class KeyAndCert { + + private KeyPair keyPair; + + private X509Certificate certificate; + + public KeyAndCert(KeyPair keyPair, X509Certificate certificate) { + this.keyPair = keyPair; + this.certificate = certificate; + } + + public KeyPair keyPair() { + return keyPair; + } + + public PublicKey publicKey() { + return keyPair.getPublic(); + } + + public PrivateKey privateKey() { + return keyPair.getPrivate(); + } + + public X509Certificate certificate() { + return certificate; + } + + public String subject() { + String dn = certificate.getSubjectDN().getName(); + int index = dn.indexOf('='); + return dn.substring(index + 1); + } + + public KeyAndCert sign(String subject) throws Exception { + KeyTool tool = new KeyTool(); + return tool.signCertificate(subject, this); + } + + public KeyAndCert sign(KeyPair keyPair, String subject) throws Exception { + KeyTool tool = new KeyTool(); + return tool.signCertificate(keyPair, subject, this); + } + + public KeyStore storeKeyAndCert(String keyPassword) throws Exception { + KeyStore result = KeyStore.getInstance("PKCS12"); + result.load(null); + + result.setKeyEntry(subject(), keyPair.getPrivate(), keyPassword.toCharArray(), + certChain()); + return result; + } + + private Certificate[] certChain() { + return new Certificate[] { certificate() }; + } + + public KeyStore storeCert() throws Exception { + return storeCert("PKCS12"); + } + + public KeyStore storeCert(String storeType) throws Exception { + KeyStore result = KeyStore.getInstance(storeType); + result.load(null); + + result.setCertificateEntry(subject(), certificate()); + return result; + } + +} diff --git a/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/KeyTool.java b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/KeyTool.java new file mode 100644 index 000000000..b1e22fea1 --- /dev/null +++ b/spring-cloud-netflix-eureka-client-tls-tests/src/test/java/org/springframework/cloud/netflix/eureka/KeyTool.java @@ -0,0 +1,126 @@ +/* + * Copyright 2018-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.netflix.eureka; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Date; + +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +public class KeyTool { + + private static final long ONE_DAY = 1000L * 60L * 60L * 24L; + + private static final long TEN_YEARS = ONE_DAY * 365L * 10L; + + public KeyAndCert createCA(String ca) throws Exception { + KeyPair keyPair = createKeyPair(); + X509Certificate certificate = createCert(keyPair, ca); + return new KeyAndCert(keyPair, certificate); + } + + public KeyAndCert signCertificate(String subject, KeyAndCert signer) + throws Exception { + return signCertificate(createKeyPair(), subject, signer); + } + + public KeyAndCert signCertificate(KeyPair keyPair, String subject, KeyAndCert signer) + throws Exception { + X509Certificate certificate = createCert(keyPair.getPublic(), signer.privateKey(), + signer.subject(), subject); + KeyAndCert result = new KeyAndCert(keyPair, certificate); + + return result; + } + + public KeyPair createKeyPair() throws Exception { + return createKeyPair(1024); + } + + public KeyPair createKeyPair(int keySize) throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(keySize, new SecureRandom()); + return gen.generateKeyPair(); + } + + public X509Certificate createCert(KeyPair keyPair, String ca) throws Exception { + JcaX509v3CertificateBuilder builder = certBuilder(keyPair.getPublic(), ca, ca); + builder.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.keyCertSign)); + builder.addExtension(Extension.basicConstraints, false, + new BasicConstraints(true)); + + return signCert(builder, keyPair.getPrivate()); + } + + public X509Certificate createCert(PublicKey publicKey, PrivateKey privateKey, + String issuer, String subject) throws Exception { + JcaX509v3CertificateBuilder builder = certBuilder(publicKey, issuer, subject); + builder.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature)); + builder.addExtension(Extension.basicConstraints, false, + new BasicConstraints(false)); + + GeneralName[] names = new GeneralName[] { + new GeneralName(GeneralName.dNSName, "localhost") }; + builder.addExtension(Extension.subjectAlternativeName, false, + GeneralNames.getInstance(new DERSequence(names))); + + return signCert(builder, privateKey); + } + + private JcaX509v3CertificateBuilder certBuilder(PublicKey publicKey, String issuer, + String subject) { + X500Name issuerName = new X500Name(String.format("dc=%s", issuer)); + X500Name subjectName = new X500Name(String.format("dc=%s", subject)); + + long now = System.currentTimeMillis(); + BigInteger serialNum = BigInteger.valueOf(now); + Date notBefore = new Date(now - ONE_DAY); + Date notAfter = new Date(now + TEN_YEARS); + + return new JcaX509v3CertificateBuilder(issuerName, serialNum, notBefore, notAfter, + subjectName, publicKey); + } + + private X509Certificate signCert(JcaX509v3CertificateBuilder builder, + PrivateKey privateKey) throws Exception { + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA") + .build(privateKey); + X509CertificateHolder holder = builder.build(signer); + + return new JcaX509CertificateConverter().getCertificate(holder); + } + +} diff --git a/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/config/DiscoveryClientOptionalArgsConfiguration.java b/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/config/DiscoveryClientOptionalArgsConfiguration.java index db8d1ec46..1458e4140 100644 --- a/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/config/DiscoveryClientOptionalArgsConfiguration.java +++ b/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/config/DiscoveryClientOptionalArgsConfiguration.java @@ -16,16 +16,23 @@ package org.springframework.cloud.netflix.eureka.config; +import java.io.IOException; +import java.security.GeneralSecurityException; + import com.netflix.discovery.AbstractDiscoveryClientOptionalArgs; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.SearchStrategy; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.configuration.SSLContextFactory; +import org.springframework.cloud.configuration.TlsProperties; import org.springframework.cloud.netflix.eureka.MutableDiscoveryClientOptionalArgs; import org.springframework.cloud.netflix.eureka.http.RestTemplateDiscoveryClientOptionalArgs; import org.springframework.cloud.netflix.eureka.http.WebClientDiscoveryClientOptionalArgs; @@ -42,6 +49,12 @@ public class DiscoveryClientOptionalArgsConfiguration { protected static final Log logger = LogFactory .getLog(DiscoveryClientOptionalArgsConfiguration.class); + @Bean + @ConfigurationProperties("eureka.client.tls") + public TlsProperties tlsProperties() { + return new TlsProperties(); + } + @Bean @ConditionalOnClass(name = "org.springframework.web.client.RestTemplate") @ConditionalOnMissingClass("com.sun.jersey.api.client.filter.ClientFilter") @@ -49,18 +62,32 @@ public class DiscoveryClientOptionalArgsConfiguration { search = SearchStrategy.CURRENT) @ConditionalOnProperty(prefix = "eureka.client", name = "webclient.enabled", matchIfMissing = true, havingValue = "false") - public RestTemplateDiscoveryClientOptionalArgs restTemplateDiscoveryClientOptionalArgs() { + public RestTemplateDiscoveryClientOptionalArgs restTemplateDiscoveryClientOptionalArgs( + TlsProperties tlsProperties) throws GeneralSecurityException, IOException { logger.info("Eureka HTTP Client uses RestTemplate."); - return new RestTemplateDiscoveryClientOptionalArgs(); + RestTemplateDiscoveryClientOptionalArgs result = new RestTemplateDiscoveryClientOptionalArgs(); + setupTLS(result, tlsProperties); + return result; } @Bean @ConditionalOnClass(name = "com.sun.jersey.api.client.filter.ClientFilter") @ConditionalOnMissingBean(value = AbstractDiscoveryClientOptionalArgs.class, search = SearchStrategy.CURRENT) - public MutableDiscoveryClientOptionalArgs discoveryClientOptionalArgs() { - logger.info("Eureka Client uses Jersey"); - return new MutableDiscoveryClientOptionalArgs(); + public MutableDiscoveryClientOptionalArgs discoveryClientOptionalArgs( + TlsProperties tlsProperties) throws GeneralSecurityException, IOException { + logger.info("Eureka HTTP Client uses Jersey"); + MutableDiscoveryClientOptionalArgs result = new MutableDiscoveryClientOptionalArgs(); + setupTLS(result, tlsProperties); + return result; + } + + private static void setupTLS(AbstractDiscoveryClientOptionalArgs args, + TlsProperties properties) throws GeneralSecurityException, IOException { + if (properties.isEnabled()) { + SSLContextFactory factory = new SSLContextFactory(properties); + args.setSSLContext(factory.createSSLContext()); + } } @ConditionalOnMissingClass("com.sun.jersey.api.client.filter.ClientFilter") @@ -70,15 +97,22 @@ public class DiscoveryClientOptionalArgsConfiguration { havingValue = "true") protected static class WebClientConfiguration { + @Autowired + private TlsProperties tlsProperties; + @Bean @ConditionalOnMissingBean( value = { AbstractDiscoveryClientOptionalArgs.class, RestTemplateDiscoveryClientOptionalArgs.class }, search = SearchStrategy.CURRENT) public WebClientDiscoveryClientOptionalArgs webClientDiscoveryClientOptionalArgs( - ObjectProvider builder) { + ObjectProvider builder) + throws GeneralSecurityException, IOException { logger.info("Eureka HTTP Client uses WebClient."); - return new WebClientDiscoveryClientOptionalArgs(builder::getIfAvailable); + WebClientDiscoveryClientOptionalArgs result = new WebClientDiscoveryClientOptionalArgs( + builder::getIfAvailable); + setupTLS(result, tlsProperties); + return result; } } diff --git a/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/config/EurekaTlsProperties.java b/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/config/EurekaTlsProperties.java new file mode 100644 index 000000000..9e9d17a2b --- /dev/null +++ b/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/config/EurekaTlsProperties.java @@ -0,0 +1,32 @@ +/* + * Copyright 2017-2020 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.netflix.eureka.config; + +import org.springframework.cloud.configuration.TlsProperties; + +/** + * Eureka client TLS properties. + */ + +public class EurekaTlsProperties extends TlsProperties { + + /** + * Prefix for Eureka client TLS properties. + */ + public static final String PREFIX = "eureka.client.tls"; + +}