diff --git a/README.adoc b/README.adoc index 6d39351a..3c1ba8b2 100644 --- a/README.adoc +++ b/README.adoc @@ -74,10 +74,10 @@ backend is enabled which accesses secret config settings via JSON endpoints. The HTTP service has resources in the form: ---- -/secret/{application} /secret/{application}/{profile} -/secret/{defaultContext} +/secret/{application} /secret/{defaultContext}/{profile} +/secret/{defaultContext} ---- where the "application" is injected as the `spring.application.name` in the diff --git a/docs/src/main/asciidoc/spring-cloud-vault-config.adoc b/docs/src/main/asciidoc/spring-cloud-vault-config.adoc index 18873934..bb8be05d 100644 --- a/docs/src/main/asciidoc/spring-cloud-vault-config.adoc +++ b/docs/src/main/asciidoc/spring-cloud-vault-config.adoc @@ -125,6 +125,63 @@ public class MyUserIdMechanism implements AppIdUserIdMechanism { } ---- +=== AWS-EC2 authentication + +The https://www.vaultproject.io/docs/auth/aws-ec2.html[aws-ec2] +auth backend provides a secure introduction mechanism +for AWS EC2 instances, allowing automated retrieval of a Vault +token. Unlike most Vault authentication backends, this backend +does not require first-deploying, or provisioning security-sensitive +credentials (tokens, username/password, client certificates, etc). +Instead, it treats AWS as a Trusted Third Party and uses the +cryptographically signed dynamic metadata information that uniquely +represents each EC2 instance. + +[source,yaml] +.bootstrap.yml using AWS-EC2 Authentication +---- +spring.cloud.vault: + enabled: true + authentication: AWS_EC2 +---- + +AWS-EC2 authentication enables nonce by default to follow +the Trust On First Use (TOFU) principle. Any unintended party that +gains access to the PKCS#7 signed identity metadata can authenticate +against Vault. + +During the first login, Spring Cloud Vault generates a nonce +that is stored in the auth backend aside the instance Id. +Re-authentication requires the same nonce to be sent. Any other +party does not have the nonce and can raise an alert in Vault for +further investigation. + +The nonce is kept in memory and is lost during application restart. + +[source,yaml] +.bootstrap.yml with disabled nonce +---- +spring.cloud.vault: + enabled: true + authentication: AWS_EC2 + aws-ec2: + use-nonce: false +---- + +AWS-EC2 authentication roles are optional and default to the AMI. +You can configure the authentication role by setting the +`spring.cloud.vault.aws-ec2.role` property. + +[source,yaml] +.bootstrap.yml with configured role +---- +spring.cloud.vault: + enabled: true + authentication: AWS_EC2 + aws-ec2: + role: application-server +---- + == Backends [[vault-client-generic]] diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java index 6884931f..4567058b 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java @@ -116,6 +116,12 @@ class VaultPropertySource extends EnumerablePropertySource { return vaultState.getToken(); } + if (vaultProperties.getAuthentication() == AuthenticationMethod.AWS_EC2) { + + vaultState.setToken(clientAuthentication.login()); + return vaultState.getToken(); + } + if (vaultProperties.getAuthentication() == AuthenticationMethod.APPID) { AppIdProperties appIdProperties = vaultProperties.getAppId(); diff --git a/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/DefaultClientAuthentication.java b/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/DefaultClientAuthentication.java index be6eb8d3..292c6f95 100644 --- a/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/DefaultClientAuthentication.java +++ b/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/DefaultClientAuthentication.java @@ -19,11 +19,13 @@ import static org.springframework.cloud.vault.VaultClient.*; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestTemplate; @@ -41,16 +43,16 @@ class DefaultClientAuthentication extends ClientAuthentication { private final VaultProperties properties; private final RestTemplate restTemplate; private final AppIdUserIdMechanism appIdUserIdMechanism; + private char[] nonce; /** * Creates a {@link DefaultClientAuthentication} using {@link VaultProperties} and * {@link RestTemplate}. - * - * @param properties must not be {@literal null} - * @param restTemplate must not be {@literal null} + * + * @param properties must not be {@literal null}. + * @param restTemplate must not be {@literal null}. */ - public DefaultClientAuthentication(VaultProperties properties, - RestTemplate restTemplate) { + DefaultClientAuthentication(VaultProperties properties, RestTemplate restTemplate) { Assert.notNull(properties, "VaultProperties must not be null"); Assert.notNull(restTemplate, "RestTemplate must not be null"); @@ -64,12 +66,12 @@ class DefaultClientAuthentication extends ClientAuthentication { * Creates a {@link DefaultClientAuthentication} using {@link VaultProperties} and * {@link RestTemplate} for AppId authentication. * - * @param properties must not be {@literal null} - * @param restTemplate must not be {@literal null} - * @param appIdUserIdMechanism must not be {@literal null} + * @param properties must not be {@literal null}. + * @param restTemplate must not be {@literal null}. + * @param appIdUserIdMechanism must not be {@literal null}. */ - public DefaultClientAuthentication(VaultProperties properties, - RestTemplate restTemplate, AppIdUserIdMechanism appIdUserIdMechanism) { + DefaultClientAuthentication(VaultProperties properties, RestTemplate restTemplate, + AppIdUserIdMechanism appIdUserIdMechanism) { Assert.notNull(properties, "VaultProperties must not be null"); Assert.notNull(restTemplate, "RestTemplate must not be null"); @@ -85,11 +87,20 @@ class DefaultClientAuthentication extends ClientAuthentication { if (properties.getAuthentication() == VaultProperties.AuthenticationMethod.APPID && appIdUserIdMechanism != null) { + log.info("Using AppId authentication to log into Vault"); + VaultProperties.AppIdProperties appId = properties.getAppId(); return createTokenUsingAppId(new AppIdTuple(properties.getApplicationName(), appIdUserIdMechanism.createUserId()), appId); } + if (properties + .getAuthentication() == VaultProperties.AuthenticationMethod.AWS_EC2) { + log.info("Using AWS-EC2 authentication to log into Vault"); + + return createTokenUsingAwsEc2(); + } + throw new UnsupportedOperationException( String.format("Cannot create a token for auth method %s", properties.getAuthentication())); @@ -128,6 +139,11 @@ class DefaultClientAuthentication extends ClientAuthentication { String.format("Cannot login using app-id: %s", VaultErrorMessage.getError(e.getResponseBodyAsString()))); } + if (e.getStatusCode().equals(HttpStatus.BAD_REQUEST)) { + throw new IllegalStateException( + String.format("Cannot login using app-id: %s", + VaultErrorMessage.getError(e.getResponseBodyAsString()))); + } throw e; } @@ -141,6 +157,87 @@ class DefaultClientAuthentication extends ClientAuthentication { return login; } + @SuppressWarnings("unchecked") + private VaultToken createTokenUsingAwsEc2() { + + VaultProperties.AwsEc2Properties properties = this.properties.getAwsEc2(); + + String url = buildUrl(); + Map variables = new HashMap<>(); + variables.put("backend", "auth/" + properties.getAwsEc2Path()); + variables.put("key", "login"); + + try { + + Map login = getEc2Login(properties); + + ResponseEntity response = restTemplate.postForEntity(url, + new HttpEntity<>(login), VaultResponse.class, variables); + + HttpStatus status = response.getStatusCode(); + if (!status.is2xxSuccessful()) { + throw new IllegalStateException("Cannot login using AWS-EC2"); + } + + VaultResponse body = response.getBody(); + String token = (String) body.getAuth().get("client_token"); + + if (log.isDebugEnabled()) { + + if (body.getAuth().get("metadata") instanceof Map) { + Map metadata = (Map) body.getAuth() + .get("metadata"); + log.debug(String.format( + "Login successful using AWS-EC2 authentication for instance %s, AMI %s", + metadata.get("instance_id"), metadata.get("instance_id"))); + } + else { + log.debug("Login successful using AWS-EC2 authentication"); + } + } + + return VaultToken.of(token, body.getLeaseDuration()); + } + catch (HttpStatusCodeException e) { + + if (e.getStatusCode().equals(HttpStatus.BAD_REQUEST)) { + throw new IllegalStateException( + String.format("Cannot login using AWS-EC2: %s", + VaultErrorMessage.getError(e.getResponseBodyAsString()))); + } + + throw e; + } + } + + private Map getEc2Login(VaultProperties.AwsEc2Properties properties) { + + Map login = new HashMap<>(); + + if (StringUtils.hasText(properties.getRole())) { + login.put("role", properties.getRole()); + } + + if (properties.isUseNonce()) { + if (this.nonce == null) { + this.nonce = createNonce(); + } + + login.put("nonce", new String(this.nonce)); + } + + String pkcs7 = restTemplate.getForObject(properties.getIdentityDocument(), + String.class); + if (StringUtils.hasText(pkcs7)) { + login.put("pkcs7", pkcs7.replaceAll("\\r", "").replace("\\n", "")); + } + return login; + } + + private char[] createNonce() { + return UUID.randomUUID().toString().toCharArray(); + } + @Value private static class AppIdTuple { private String appId; diff --git a/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/VaultProperties.java b/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/VaultProperties.java index 7ac5f2ff..2e8c3ef0 100644 --- a/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/VaultProperties.java +++ b/spring-cloud-vault-core/src/main/java/org/springframework/cloud/vault/VaultProperties.java @@ -74,6 +74,8 @@ public class VaultProperties { private AppIdProperties appId = new AppIdProperties(); + private AwsEc2Properties awsEc2 = new AwsEc2Properties(); + private Ssl ssl = new Ssl(); /** @@ -89,12 +91,14 @@ public class VaultProperties { /** * Property value for UserId generation using a Mac-Address. + * * @see MacAddressUserId */ public final static String MAC_ADDRESS = "MAC_ADDRESS"; /** * Property value for UserId generation using an IP-Address. + * * @see IpAddressUserId */ public final static String IP_ADDRESS = "IP_ADDRESS"; @@ -117,6 +121,32 @@ public class VaultProperties { private String userId = MAC_ADDRESS; } + @Data + public static class AwsEc2Properties { + + /** + * URL of the AWS-EC2 PKCS7 identity document. + */ + @NotEmpty + private String identityDocument = "http://169.254.169.254/latest/dynamic/instance-identity/pkcs7"; + + /** + * Mount path of the AWS-EC2 authentication backend. + */ + @NotEmpty + private String awsEc2Path = "aws-ec2"; + + /** + * Name of the role, optional. + */ + private String role = ""; + + /** + * Flag whether to generate and send a nonce. + */ + private boolean useNonce = true; + } + @Data public static class Ssl { @@ -132,6 +162,6 @@ public class VaultProperties { } public enum AuthenticationMethod { - TOKEN, APPID, + TOKEN, APPID, AWS_EC2, } } diff --git a/spring-cloud-vault-core/src/test/java/org/springframework/cloud/vault/AwsEc2AuthenticationMethodsIntegrationTests.java b/spring-cloud-vault-core/src/test/java/org/springframework/cloud/vault/AwsEc2AuthenticationMethodsIntegrationTests.java new file mode 100644 index 00000000..cf7057be --- /dev/null +++ b/spring-cloud-vault-core/src/test/java/org/springframework/cloud/vault/AwsEc2AuthenticationMethodsIntegrationTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2016 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.vault; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.Assume.*; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.boot.test.IntegrationTest; +import org.springframework.cloud.vault.VaultProperties.AuthenticationMethod; +import org.springframework.cloud.vault.util.Settings; +import org.springframework.util.StringUtils; + +/** + * Integration tests for {@link VaultClient} using AWS-EC2 login. This test requires AWS + * credentials, a region and an AMI, see {@link #AWS_ACCESS_KEY}, {@link #AWS_SECRET_KEY} + * and the {@link IntegrationTest} properties to be provided externally. It needs to be + * run on a AWS-EC2 instance to be able to obtain instance metadata. + * + * @author Mark Paluch + */ +public class AwsEc2AuthenticationMethodsIntegrationTests + extends AbstractIntegrationTests { + + private final static String AWS_REGION = "eu-west-1"; + private final static String AWS_AMI = "ami-f95ef58a"; + private final static String AWS_ACCESS_KEY = System.getProperty("aws.access.key"); + private final static String AWS_SECRET_KEY = System.getProperty("aws.secret.key"); + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Before + public void setUp() throws Exception { + + assumeTrue(StringUtils.hasText(AWS_ACCESS_KEY) + && StringUtils.hasText(AWS_SECRET_KEY)); + + if (!prepare().hasAuth("aws-ec2")) { + prepare().mountAuth("aws-ec2"); + } + + Map config = new HashMap<>(); + config.put("access_key", AWS_ACCESS_KEY); + config.put("secret_key", AWS_SECRET_KEY); + config.put("endpoint", String.format("https://ec2.%s.amazonaws.com", AWS_REGION)); + + prepare().write("auth/aws-ec2/config/client", config); + + prepare().write(String.format("auth/aws-ec2/role/%s", AWS_AMI), + Collections.singletonMap("bound_ami_id", AWS_AMI)); + } + + @Test + public void loginShouldCreateAToken() throws Exception { + + VaultProperties vaultProperties = prepareAwsEc2Authentication(); + ClientAuthentication clientAuthentication = new DefaultClientAuthentication( + vaultProperties, TestRestTemplateFactory.create(vaultProperties)); + + assertThat(clientAuthentication.login()).isNotNull(); + } + + private VaultProperties prepareAwsEc2Authentication() { + + VaultProperties vaultProperties = Settings.createVaultProperties(); + vaultProperties.setAuthentication(AuthenticationMethod.AWS_EC2); + + return vaultProperties; + } +}