From 5d15afabe58c702680562fc0cbd5f3286edd7c00 Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Fri, 10 Oct 2014 15:57:50 +0100 Subject: [PATCH] Add HTTP Basic security --- .../main/asciidoc/spring-cloud-config.adoc | 54 +++++++- spring-cloud-config-client/pom.xml | 4 + .../EncryptionBootstrapConfiguration.java | 38 +++++- ...EnvironmentDecryptApplicationListener.java | 13 +- .../ConfigServicePropertySourceLocator.java | 124 +++++++++++++++++- ...nfigServicePropertySourceLocatorTests.java | 56 ++++++++ .../server/ConfigServerConfiguration.java | 2 +- 7 files changed, 277 insertions(+), 14 deletions(-) create mode 100644 spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocatorTests.java diff --git a/docs/src/main/asciidoc/spring-cloud-config.adoc b/docs/src/main/asciidoc/spring-cloud-config.adoc index 33e960ad..597df73b 100644 --- a/docs/src/main/asciidoc/spring-cloud-config.adoc +++ b/docs/src/main/asciidoc/spring-cloud-config.adoc @@ -14,6 +14,20 @@ configuration (name-value pairs, or equivalent YAML content). The server is easily embeddable in a Spring Boot application using the `@EnableConfigServer` annotation. +=== Security + +You are free to secure your Config Server in any way that makes sense +to you (from physical network security to OAuth2 bearer +tokens), and Spring Security and Spring Boot make it easy to do pretty +much anything. + +To use the default Spring Boot configured HTTP Basic security, just +include Spring Security on the classpath (e.g. through +`spring-boot-starter-security`). The default is a username of "user" +and a randomly generated password, which isn't going to be very useful +in practice, so we recommend you configure the password (via +`security.user.password`) and encrypt it (see below for instructions +on how to do that). === Encryption and Decryption @@ -29,7 +43,9 @@ agents). If the remote property sources contain encryted content (values starting with `{cipher}`) they will be decrypted before sending to clients over HTTP. The main advantage of this set up is that the property values don't have to be in plain text when they are -"at rest" (e.g. in a git repository). +"at rest" (e.g. in a git repository). If a value cannot be decrypted +it is replaced with an empty string, largely to prevent cipher text +being used as a password in Spring Boot autconfigured HTTP basic. If you are setting up a remote config repository for config client applications it might contain an `application.yml` like this, for @@ -148,6 +164,7 @@ client starts up it binds to the Config Server (via the bootstrap configuration property `spring.cloud.config.uri`) and initializes Spring `Environment` with remote property sources + === Environment Changes The application will listen for an `EnvironmentChangedEvent` and react @@ -297,3 +314,38 @@ org.springframework.cloud.bootstrap.BootstrapConfiguration=sample.custom.CustomP then the "customProperty" `PropertySource` will show up in any application that includes that jar on its classpath. + +=== Security + +If you use HTTP Basic security on the server then clients just need to +know the password (and username if it isn't the default). You can do +that via the config server URI, or via separate username and password +properties, e.g. + +.bootstrap.yml +---- +spring: + cloud: + config: + uri: https://user:secret@myconfig.mycompany.com +---- + +or + +.bootstrap.yml +---- +spring: + cloud: + config: + uri: https://myconfig.mycompany.com + username: user + password: secret +---- + +The `spring.cloud.config.password` and `spring.cloud.config.username` +values override anything that is provided in the URI. + +If you use another form of security you might need to provide a +`RestTemplate` to the `ConfigServicePropertySourceLocator` (e.g. by +grabbing it in the bootstrap context and injecting one). + diff --git a/spring-cloud-config-client/pom.xml b/spring-cloud-config-client/pom.xml index 84ad2fbc..c5ead3e5 100644 --- a/spring-cloud-config-client/pom.xml +++ b/spring-cloud-config-client/pom.xml @@ -36,6 +36,10 @@ spring-boot-starter-aop true + + org.springframework.security + spring-security-crypto + org.springframework.security spring-security-rsa diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EncryptionBootstrapConfiguration.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EncryptionBootstrapConfiguration.java index 5cedf047..ea019aff 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EncryptionBootstrapConfiguration.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EncryptionBootstrapConfiguration.java @@ -22,7 +22,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.SpringBootCondition; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.cloud.bootstrap.encrypt.EncryptionBootstrapConfiguration.KeyCondition; import org.springframework.cloud.bootstrap.encrypt.KeyProperties.KeyStore; import org.springframework.cloud.config.encrypt.EncryptorFactory; import org.springframework.context.annotation.Bean; @@ -42,11 +41,14 @@ import org.springframework.util.StringUtils; */ @Configuration @ConditionalOnClass(TextEncryptor.class) -@Conditional(KeyCondition.class) @EnableConfigurationProperties(KeyProperties.class) public class EncryptionBootstrapConfiguration { + @Autowired(required = false) + private TextEncryptor encryptor; + @Configuration + @Conditional(KeyCondition.class) @ConditionalOnClass(RsaSecretEncryptor.class) protected static class RsaEncryptionConfiguration { @@ -57,7 +59,7 @@ public class EncryptionBootstrapConfiguration { @ConditionalOnMissingBean(TextEncryptor.class) public TextEncryptor textEncryptor() { KeyStore keyStore = key.getKeyStore(); - if (keyStore.getLocation()!=null && keyStore.getLocation().exists()) { + if (keyStore.getLocation() != null && keyStore.getLocation().exists()) { return new RsaSecretEncryptor( new KeyStoreKeyFactory(keyStore.getLocation(), keyStore .getPassword().toCharArray()).getKeyPair(keyStore @@ -69,6 +71,7 @@ public class EncryptionBootstrapConfiguration { } @Configuration + @Conditional(KeyCondition.class) @ConditionalOnMissingClass(name = "org.springframework.security.rsa.crypto.RsaSecretEncryptor") protected static class VanillaEncryptionConfiguration { @@ -84,8 +87,10 @@ public class EncryptionBootstrapConfiguration { } @Bean - public EnvironmentDecryptApplicationListener environmentDecryptApplicationListener( - TextEncryptor encryptor) { + public EnvironmentDecryptApplicationListener environmentDecryptApplicationListener() { + if (encryptor == null) { + encryptor = new FailsafeTextEncryptor(); + } return new EnvironmentDecryptApplicationListener(encryptor); } @@ -118,4 +123,27 @@ public class EncryptionBootstrapConfiguration { } + /** + * TextEncryptor that just fails, so that users don't get a false sense of security + * adding ciphers to config files and not getting them decrypted. + * + * @author Dave Syer + * + */ + protected static class FailsafeTextEncryptor implements TextEncryptor { + + @Override + public String encrypt(String text) { + throw new UnsupportedOperationException( + "No encryption for FailsafeTextEncryptor. Did you configure the keystore correctly?"); + } + + @Override + public String decrypt(String encryptedText) { + throw new UnsupportedOperationException( + "No decryption for FailsafeTextEncryptor. Did you configure the keystore correctly?"); + } + + } + } diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EnvironmentDecryptApplicationListener.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EnvironmentDecryptApplicationListener.java index 78ae0a2a..54d9a0e7 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EnvironmentDecryptApplicationListener.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/bootstrap/encrypt/EnvironmentDecryptApplicationListener.java @@ -94,11 +94,20 @@ public class EnvironmentDecryptApplicationListener implements value = value.substring("{cipher}".length()); try { value = encryptor.decrypt(value); - overrides.put(key, value); + if (logger.isDebugEnabled()) { + logger.debug("Decrypted: key=" + key); + } } catch (Exception e) { - logger.warn("Cannot decrypt: key=" + key); + if (logger.isDebugEnabled()) { + logger.warn("Cannot decrypt: key=" + key, e); + } else { + logger.warn("Cannot decrypt: key=" + key); + } + // Set value to empty to avoid making a password out of the cipher text + value = ""; } + overrides.put(key, value); } } diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java index 9ba72446..010b131b 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java @@ -16,17 +16,28 @@ package org.springframework.cloud.config.client; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Arrays; import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.config.Environment; +import org.springframework.cloud.config.PropertySource; import org.springframework.core.env.CompositePropertySource; import org.springframework.core.env.MapPropertySource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; -import org.springframework.cloud.config.Environment; -import org.springframework.cloud.config.PropertySource; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.security.crypto.codec.Base64; +import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; /** * @author Dave Syer @@ -42,14 +53,19 @@ public class ConfigServicePropertySourceLocator implements PropertySourceLocator private String label = "master"; + private String username; + + private String password; + private String uri = "http://localhost:8888"; - private RestTemplate restTemplate = new RestTemplate(); + private RestTemplate restTemplate; @Override public org.springframework.core.env.PropertySource locate() { CompositePropertySource composite = new CompositePropertySource("configService"); - Environment result = restTemplate.exchange(uri + "/{name}/{env}/{label}", + RestTemplate restTemplate = this.restTemplate==null ? getSecureRestTemplate() : this.restTemplate; + Environment result = restTemplate.exchange(getUri() + "/{name}/{env}/{label}", HttpMethod.GET, new HttpEntity((Void) null), Environment.class, name, env, label).getBody(); for (PropertySource source : result.getPropertySources()) { @@ -60,8 +76,12 @@ public class ConfigServicePropertySourceLocator implements PropertySourceLocator return composite; } + public void setRestTemplate(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + public String getUri() { - return uri; + return extractCredentials()[2]; } public void setUri(String url) { @@ -92,4 +112,98 @@ public class ConfigServicePropertySourceLocator implements PropertySourceLocator this.label = label; } + public String getUsername() { + return extractCredentials()[0]; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return extractCredentials()[1]; + } + + public void setPassword(String password) { + this.password = password; + } + + private RestTemplate getSecureRestTemplate() { + RestTemplate template = new RestTemplate(); + String[] userInfo = extractCredentials(); + if (userInfo[1]!=null) { + template.setInterceptors(Arrays + . asList(new BasicAuthorizationInterceptor( + userInfo[0], userInfo[1]))); + } + return template; + } + + private String[] extractCredentials() { + String[] result = new String[3]; + String uri = this.uri; + result[2] = uri; + String[] creds = getUsernamePassword(); + result[0] = creds[0]; + result[1] = creds[1]; + try { + URL url = new URL(uri); + String userInfo = url.getUserInfo(); + if (StringUtils.isEmpty(userInfo) || ":".equals(userInfo)) { + return result; + } + String bare = UriComponentsBuilder.fromHttpUrl(uri).userInfo(null).build() + .toUriString(); + result[2] = bare; + if (!userInfo.contains(":")) { + userInfo = userInfo + ":"; + } + String[] split = userInfo.split(":"); + result[0] = split[0]; + result[1] = split[1]; + if (creds[1]!=null) { + // Explicit username / password takes precedence + result[1] = creds[1]; + if ("user".equals(creds[0])) { + // But the username can be overridden + result[0] = split[0]; + } + } + return result; + } + catch (MalformedURLException e) { + throw new IllegalStateException("Invalid URL: " + uri); + } + } + + private String[] getUsernamePassword() { + if (StringUtils.hasText(password)) { + return new String[] {StringUtils.hasText(username) ? username.trim() : "user", password.trim()}; + } + return new String[2]; + } + + private static class BasicAuthorizationInterceptor implements + ClientHttpRequestInterceptor { + + private final String username; + + private final String password; + + public BasicAuthorizationInterceptor(String username, String password) { + this.username = username; + this.password = (password == null ? "" : password); + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + byte[] token = Base64 + .encode((this.username + ":" + this.password).getBytes()); + request.getHeaders().add("Authorization", "Basic " + new String(token)); + return execution.execute(request, body); + } + + } + } diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocatorTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocatorTests.java new file mode 100644 index 00000000..43114591 --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocatorTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2013-2014 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 + * + * http://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.config.client; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * @author Dave Syer + * + */ +public class ConfigServicePropertySourceLocatorTests { + + private ConfigServicePropertySourceLocator locator = new ConfigServicePropertySourceLocator(); + + @Test + public void vanilla() { + locator.setUri("http://localhost:9999"); + locator.setPassword("secret"); + assertEquals("http://localhost:9999", locator.getUri()); + assertEquals("user", locator.getUsername()); + assertEquals("secret", locator.getPassword()); + } + + @Test + public void uriCreds() { + locator.setUri("http://foo:bar@localhost:9999"); + assertEquals("http://localhost:9999", locator.getUri()); + assertEquals("foo", locator.getUsername()); + assertEquals("bar", locator.getPassword()); + } + + @Test + public void overridePassword() { + locator.setUri("http://foo:bar@localhost:9999"); + locator.setPassword("secret"); + assertEquals("http://localhost:9999", locator.getUri()); + assertEquals("foo", locator.getUsername()); + assertEquals("secret", locator.getPassword()); + } + +} diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/ConfigServerConfiguration.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/ConfigServerConfiguration.java index 9fada595..1914fba0 100644 --- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/ConfigServerConfiguration.java +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/ConfigServerConfiguration.java @@ -50,7 +50,7 @@ public class ConfigServerConfiguration { private ConfigurableEnvironment environment; @Bean - @ConfigurationProperties("spring.platform.config.server") + @ConfigurationProperties("spring.cloud.config.server") public JGitEnvironmentRepository repository() { return new JGitEnvironmentRepository(environment); }