From 15c786d28bdadb06b88773a79e9801abda12b81a Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 13 Jul 2017 14:36:29 +0200 Subject: [PATCH] Support AWS IAM authentication. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring Vault now supports AWS IAM authentication via signed HTTP requests that are executed by Vault to determine the caller (signer) identity. AwsIamAuthentication requires AWS Java Core SDK to create the actual signature: AwsIamAuthenticationOptions options = AwsIamAuthenticationOptions.builder() .credentials(new BasicAWSCredentials(…)).build(); AwsIamAuthentication auth = new AwsIamAuthentication(options, restOperations()); Closes gh-91. --- spring-vault-core/pom.xml | 16 ++ .../AwsEc2AuthenticationOptions.java | 2 +- .../authentication/AwsIamAuthentication.java | 198 ++++++++++++++ .../AwsIamAuthenticationOptions.java | 244 ++++++++++++++++++ .../AwsIamAuthenticationUnitTests.java | 85 ++++++ spring-vault-dependencies/pom.xml | 8 + .../asciidoc/reference/authentication.adoc | 70 +++++ 7 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthentication.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthenticationOptions.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsIamAuthenticationUnitTests.java diff --git a/spring-vault-core/pom.xml b/spring-vault-core/pom.xml index ed2272dc..cc2cb20d 100644 --- a/spring-vault-core/pom.xml +++ b/spring-vault-core/pom.xml @@ -74,6 +74,22 @@ true + + com.amazonaws + aws-java-sdk-core + true + + + software.amazon.ion + ion-java + + + com.fasterxml.jackson.dataformat + jackson-dataformat-cbor + + + + org.springframework spring-test diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java index f26abf1e..ca67b874 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java @@ -189,7 +189,7 @@ public class AwsEc2AuthenticationOptions { /** * Build a new {@link AwsEc2AuthenticationOptions} instance. * - * @return a new {@link AppIdAuthenticationOptions}. + * @return a new {@link AwsEc2AuthenticationOptions}. */ public AwsEc2AuthenticationOptions build() { diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthentication.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthentication.java new file mode 100644 index 00000000..3b3b73f2 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthentication.java @@ -0,0 +1,198 @@ +/* + * Copyright 2017 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.vault.authentication; + +import java.io.ByteArrayInputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; + +import com.amazonaws.DefaultRequest; +import com.amazonaws.auth.AWS4Signer; +import com.amazonaws.http.HttpMethodName; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.Base64Utils; +import org.springframework.util.StringUtils; +import org.springframework.vault.VaultException; +import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestOperations; + +/** + * AWS IAM authentication using signed HTTP requests to query the current identity. + *

+ * AWS IAM authentication creates a {@link AWS4Signer signed} HTTP request that is + * executed by Vault to get the identity of the signer using AWS STS + * {@literal GetCallerIdentity}. A signature requires + * {@link com.amazonaws.auth.AWSCredentials} to calculate the signature. + *

+ * This authentication requires AWS' Java SDK to sign request parameters and calculate the + * signature key. Using an appropriate {@link com.amazonaws.auth.AWSCredentialsProvider} + * allows authentication within AWS-EC2 instances with an assigned profile, within ECS and + * Lambda instances. + * + * @author Mark Paluch + * @since 1.1 + * @see AwsIamAuthenticationOptions + * @see com.amazonaws.auth.AWSCredentialsProvider + * @see RestOperations + * @see Auth Backend: aws + * (IAM) + * @see AWS: + * GetCallerIdentity + */ +public class AwsIamAuthentication implements ClientAuthentication { + + private static final Log logger = LogFactory.getLog(AwsIamAuthentication.class); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String REQUEST_BODY = "Action=GetCallerIdentity&Version=2011-06-15"; + + private static final String REQUEST_BODY_BASE64_ENCODED = Base64Utils + .encodeToString(REQUEST_BODY.getBytes()); + + private final AwsIamAuthenticationOptions options; + + private final RestOperations vaultRestOperations; + + /** + * Create a new {@link AwsIamAuthentication} specifying + * {@link AwsIamAuthenticationOptions}, a Vault and an AWS-Metadata-specific + * {@link RestOperations} . + * + * @param options must not be {@literal null}. + * @param vaultRestOperations must not be {@literal null}. + */ + public AwsIamAuthentication(AwsIamAuthenticationOptions options, + RestOperations vaultRestOperations) { + + Assert.notNull(options, "AwsIamAuthenticationOptions must not be null"); + Assert.notNull(vaultRestOperations, "Vault RestOperations must not be null"); + + this.options = options; + this.vaultRestOperations = vaultRestOperations; + } + + @Override + public VaultToken login() throws VaultException { + return createTokenUsingAwsIam(); + } + + @SuppressWarnings("unchecked") + private VaultToken createTokenUsingAwsIam() { + + Map login = new HashMap(); + + login.put("iam_http_request_method", "POST"); + login.put("iam_request_url", Base64Utils.encodeToString(options.getEndpointUri() + .toString().getBytes())); + login.put("iam_request_body", REQUEST_BODY_BASE64_ENCODED); + + String headerJson = getSignedHeaders(options); + + login.put("iam_request_headers", + Base64Utils.encodeToString(headerJson.getBytes())); + + if (!StringUtils.isEmpty(options.getRole())) { + login.put("role", options.getRole()); + } + + try { + + VaultResponse response = this.vaultRestOperations.postForObject( + "auth/{mount}/login", login, VaultResponse.class, options.getPath()); + + if (logger.isDebugEnabled()) { + + if (response.getAuth().get("metadata") instanceof Map) { + Map metadata = (Map) response + .getAuth().get("metadata"); + logger.debug(String + .format("Login successful using AWS-IAM authentication for user id %s, ARN %s", + metadata.get("client_user_id"), + metadata.get("canonical_arn"))); + } + else { + logger.debug("Login successful using AWS-IAM authentication"); + } + } + + return LoginTokenUtil.from(response.getAuth()); + } + catch (HttpStatusCodeException e) { + throw new VaultException(String.format("Cannot login using AWS-IAM: %s", + VaultResponses.getError(e.getResponseBodyAsString()))); + } + } + + private static String getSignedHeaders(AwsIamAuthenticationOptions options) { + + Map headers = createIamRequestHeaders(options); + + AWS4Signer signer = new AWS4Signer(); + + DefaultRequest request = new DefaultRequest("sts"); + + request.setContent(new ByteArrayInputStream(REQUEST_BODY.getBytes())); + request.setHeaders(headers); + request.setHttpMethod(HttpMethodName.POST); + request.setEndpoint(options.getEndpointUri()); + + signer.setServiceName(request.getServiceName()); + signer.sign(request, options.getCredentialsProvider().getCredentials()); + + Map map = new LinkedHashMap(); + + for (Entry entry : request.getHeaders().entrySet()) { + map.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + + try { + return OBJECT_MAPPER.writeValueAsString(map); + } + catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot serialize headers to JSON", e); + } + } + + private static Map createIamRequestHeaders( + AwsIamAuthenticationOptions options) { + + Map headers = new LinkedHashMap(); + + headers.put(HttpHeaders.CONTENT_LENGTH, "" + REQUEST_BODY.length()); + headers.put(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + if (StringUtils.hasText(options.getServerName())) { + headers.put("X-Vault-AWS-IAM-Server-ID", options.getServerName()); + } + + return headers; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthenticationOptions.java new file mode 100644 index 00000000..9f7bc078 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthenticationOptions.java @@ -0,0 +1,244 @@ +/* + * Copyright 2017 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.vault.authentication; + +import java.net.URI; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; + +import org.springframework.util.Assert; + +/** + * Authentication options for {@link AwsIamAuthentication}. + *

+ * Authentication options provide the path, a {@link AWSCredentialsProvider} optional role + * and server name. {@link AwsIamAuthenticationOptions} can be constructed using + * {@link #builder()}. Instances of this class are immutable once constructed. + * + * @author Mark Paluch + * @since 1.1 + * @see AwsIamAuthentication + * @see #builder() + */ +public class AwsIamAuthenticationOptions { + + public static final String DEFAULT_AWS_AUTHENTICATION_PATH = "aws"; + + /** + * Path of the aws authentication backend mount. + */ + private final String path; + + /** + * Credential provider. + */ + private final AWSCredentialsProvider credentialsProvider; + + /** + * EC2 instance role name. May be {@literal null} if none. + */ + private final String role; + + /** + * Server name to mitigate risk of replay attacks, preferably set to Vault server's + * DNS name. + */ + private final String serverName; + + /** + * STS server URI. + */ + private final URI endpointUri; + + private AwsIamAuthenticationOptions(String path, + AWSCredentialsProvider credentialsProvider, String role, String serverName, + URI endpointUri) { + + this.path = path; + this.credentialsProvider = credentialsProvider; + this.role = role; + this.serverName = serverName; + this.endpointUri = endpointUri; + } + + /** + * @return a new {@link AwsIamAuthenticationOptionsBuilder}. + */ + public static AwsIamAuthenticationOptionsBuilder builder() { + return new AwsIamAuthenticationOptionsBuilder(); + } + + /** + * @return the path of the aws authentication backend mount. + */ + public String getPath() { + return path; + } + + /** + * @return the credentials provider to obtain AWS credentials. + */ + public AWSCredentialsProvider getCredentialsProvider() { + return credentialsProvider; + } + + /** + * @return the role, may be {@literal null} if none. + */ + public String getRole() { + return role; + } + + /** + * @return Server name to mitigate risk of replay attacks, preferably set to Vault + * server's DNS name, may be {@literal null}. + */ + public String getServerName() { + return serverName; + } + + /** + * @return STS server URI. + */ + public URI getEndpointUri() { + return endpointUri; + } + + /** + * Builder for {@link AwsIamAuthenticationOptions}. + */ + public static class AwsIamAuthenticationOptionsBuilder { + + private String path = DEFAULT_AWS_AUTHENTICATION_PATH; + private AWSCredentialsProvider credentialsProvider; + private String role; + private String serverName; + private URI endpointUri = URI.create("https://sts.amazonaws.com/"); + + AwsIamAuthenticationOptionsBuilder() { + } + + /** + * Configure the mount path, defaults to {@literal aws}. + * + * @param path must not be empty or {@literal null}. + * @return {@code this} {@link AwsIamAuthenticationOptionsBuilder}. + */ + public AwsIamAuthenticationOptionsBuilder path(String path) { + + Assert.hasText(path, "Path must not be empty"); + + this.path = path; + return this; + } + + /** + * Configure static AWS credentials, required to calculate the signature. Either + * use static credentials or provide a + * {@link #credentialsProvider(AWSCredentialsProvider) credentials provider}. + * + * @param credentials must not be {@literal null}. + * @return {@code this} {@link AwsIamAuthenticationOptionsBuilder}. + * @see #credentialsProvider(AWSCredentialsProvider) + */ + public AwsIamAuthenticationOptionsBuilder credentials(AWSCredentials credentials) { + + Assert.notNull(credentials, "Credentials must not be null"); + + return credentialsProvider(new AWSStaticCredentialsProvider(credentials)); + } + + /** + * Configure an {@link AWSCredentialsProvider}, required to calculate the + * signature. Alternatively, configure static {@link #credentials(AWSCredentials) + * credentials}. + * + * @param credentialsProvider must not be {@literal null}. + * @return {@code this} {@link AwsIamAuthenticationOptionsBuilder}. + * @see #credentials(AWSCredentials) + */ + public AwsIamAuthenticationOptionsBuilder credentialsProvider( + AWSCredentialsProvider credentialsProvider) { + + Assert.notNull(credentialsProvider, "AWSCredentialsProvider must not be null"); + + this.credentialsProvider = credentialsProvider; + return this; + } + + /** + * Configure the name of the role against which the login is being attempted.If + * role is not specified, the friendly name (i.e., role name or username) of the + * IAM principal authenticated. If a matching role is not found, login fails. + * + * @param role must not be empty or {@literal null}. + * @return {@code this} {@link AwsIamAuthenticationOptionsBuilder}. + */ + public AwsIamAuthenticationOptionsBuilder role(String role) { + + Assert.hasText(role, "Role must not be null or empty"); + + this.role = role; + return this; + } + + /** + * Configure a server name that is included in the signature to mitigate the risk + * of replay attacks. Preferably use the Vault server DNS name. + * + * @param serverName must not be {@literal null} or empty. + * @return {@code this} {@link AwsIamAuthenticationOptionsBuilder}. + */ + public AwsIamAuthenticationOptionsBuilder serverName(String serverName) { + + Assert.hasText(serverName, "Server name must not be null or empty"); + + this.serverName = serverName; + return this; + } + + /** + * Configure an endpoint URI of the STS API, defaults to + * {@literal https://sts.amazonaws.com/}. + * + * @param endpointUri must not be {@literal null}. + * @return {@code this} {@link AwsIamAuthenticationOptionsBuilder}. + */ + public AwsIamAuthenticationOptionsBuilder endpointUri(URI endpointUri) { + + Assert.notNull(endpointUri, "Endpoint URI must not be null"); + + this.endpointUri = endpointUri; + return this; + } + + /** + * Build a new {@link AwsIamAuthenticationOptions} instance. + * + * @return a new {@link AwsIamAuthenticationOptions}. + */ + public AwsIamAuthenticationOptions build() { + + Assert.state(credentialsProvider != null, + "Credentials or CredentialProvider must not be null"); + + return new AwsIamAuthenticationOptions(path, credentialsProvider, role, + serverName, endpointUri); + } + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsIamAuthenticationUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsIamAuthenticationUnitTests.java new file mode 100644 index 00000000..1f558557 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsIamAuthenticationUnitTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2017 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.vault.authentication; + +import com.amazonaws.auth.BasicAWSCredentials; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.vault.client.VaultClients; +import org.springframework.vault.client.VaultClients.PrefixAwareUriTemplateHandler; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Unit test for {@link AwsIamAuthentication}. + * + * @author Mark Paluch + */ +public class AwsIamAuthenticationUnitTests { + + private RestTemplate restTemplate; + private MockRestServiceServer mockRest; + + @Before + public void before() throws Exception { + + RestTemplate restTemplate = VaultClients.createRestTemplate(); + restTemplate.setUriTemplateHandler(new PrefixAwareUriTemplateHandler()); + + this.mockRest = MockRestServiceServer.createServer(restTemplate); + this.restTemplate = restTemplate; + } + + @Test + public void shouldAuthenticate() { + + mockRest.expect(requestTo("/auth/aws/login")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.iam_http_request_method").value("POST")) + .andExpect(jsonPath("$.iam_request_url").exists()) + .andExpect(jsonPath("$.iam_request_body").exists()) + .andExpect(jsonPath("$.iam_request_headers").exists()) + .andExpect(jsonPath("$.role").value("foo-role")) + .andRespond( + withSuccess() + .contentType(MediaType.APPLICATION_JSON) + .body("{" + + "\"auth\":{\"client_token\":\"my-token\", \"renewable\": true, \"lease_duration\": 10}" + + "}")); + + AwsIamAuthenticationOptions options = AwsIamAuthenticationOptions.builder() + .role("foo-role").credentials(new BasicAWSCredentials("foo", "bar")) + .build(); + AwsIamAuthentication sut = new AwsIamAuthentication(options, restTemplate); + + VaultToken login = sut.login(); + + assertThat(login).isInstanceOf(LoginToken.class); + assertThat(login.getToken()).isEqualTo("my-token"); + assertThat(((LoginToken) login).getLeaseDuration()).isEqualTo(10); + assertThat(((LoginToken) login).isRenewable()).isTrue(); + } +} \ No newline at end of file diff --git a/spring-vault-dependencies/pom.xml b/spring-vault-dependencies/pom.xml index 75769fe8..43cd46e1 100644 --- a/spring-vault-dependencies/pom.xml +++ b/spring-vault-dependencies/pom.xml @@ -124,6 +124,14 @@ true + + + com.amazonaws + aws-java-sdk-core + 1.11.161 + true + + diff --git a/src/main/asciidoc/reference/authentication.adoc b/src/main/asciidoc/reference/authentication.adoc index f2a06a0a..a1032cc7 100644 --- a/src/main/asciidoc/reference/authentication.adoc +++ b/src/main/asciidoc/reference/authentication.adoc @@ -277,6 +277,76 @@ it in `AwsEc2AuthenticationOptions`. See also: https://www.vaultproject.io/docs/auth/aws-ec2.html[Vault Documentation: Using the AWS-EC2 auth backend] +[[vault.authentication.awsiam]] +== AWS-IAM authentication + +The https://www.vaultproject.io/docs/auth/aws.html[aws] +auth backend allows Vault login by using existing AWS IAM credentials. + +AWS IAM authentication creates a signed HTTP request that is +executed by Vault to get the identity of the signer using AWS STS +`GetCallerIdentity` method. AWSv4 signatures require IAM credentials. + +IAM credentials can be obtained from either the runtime environment +or supplied externally. Runtime environments such as AWS-EC2, +Lambda and ECS with assigned IAM principals do not require client-specific +configuration of credentials but can obtain these from its metadata source. + +==== +[source,java] +---- +@Configuration +class AppConfig extends AbstractVaultConfiguration { + + // … + + @Override + public ClientAuthentication clientAuthentication() { + + AwsIamAuthenticationOptions options = AwsIamAuthenticationOptions.builder() + .credentials(new BasicAWSCredentials(…)).build(); + + return new AwsIamAuthentication(options, restOperations()); + } + + // … +} +---- +==== + +.Using AWS-EC2 instance profile as credentials source +==== +[source,java] +---- +@Configuration +class AppConfig extends AbstractVaultConfiguration { + + // … + + @Override + public ClientAuthentication clientAuthentication() { + + AwsIamAuthenticationOptions options = AwsIamAuthenticationOptions.builder() + .credentialsProvider(InstanceProfileCredentialsProvider.getInstance()).build(); + + return new AwsIamAuthentication(options, restOperations()); + } + + // … +} +---- +==== + +`AwsIamAuthentication` requires the AWS Java SDK dependency (`com.amazonaws:aws-java-sdk-core`) +as the authentication implementation uses AWS SDK types for credentials and request signing. + +You can configure the authentication via `AwsIamAuthenticationOptions`. + +See also: + +* https://www.vaultproject.io/docs/auth/aws.html[Vault Documentation: Using the AWS auth backend] +* http://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html[AWS Documentation: STS GetCallerIdentity] + [[vault.authentication.clientcert]] == TLS certificate authentication