diff --git a/spring-vault-core/pom.xml b/spring-vault-core/pom.xml index 66e03f72..b86f376b 100644 --- a/spring-vault-core/pom.xml +++ b/spring-vault-core/pom.xml @@ -95,6 +95,22 @@ + + 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 c3693863..8007a748 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 @@ -195,7 +195,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..92271804 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthentication.java @@ -0,0 +1,201 @@ +/* + * 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()); + + Assert.state(response != null && response.getAuth() != null, + "Auth field must not be null"); + + 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..91d1217c --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsIamAuthenticationOptions.java @@ -0,0 +1,256 @@ +/* + * 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.lang.Nullable; +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. + */ + @Nullable + private final String role; + + /** + * Server name to mitigate risk of replay attacks, preferably set to Vault server's + * DNS name. + */ + @Nullable + private final String serverName; + + /** + * STS server URI. + */ + private final URI endpointUri; + + private AwsIamAuthenticationOptions(String path, + AWSCredentialsProvider credentialsProvider, @Nullable String role, + @Nullable 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. + */ + @Nullable + 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}. + */ + @Nullable + 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; + + @Nullable + private AWSCredentialsProvider credentialsProvider; + + @Nullable + private String role; + + @Nullable + 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..30a9bcbf --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsIamAuthenticationUnitTests.java @@ -0,0 +1,88 @@ +/* + * 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.time.Duration; + +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( + Duration.ofSeconds(10)); + assertThat(((LoginToken) login).isRenewable()).isTrue(); + } +} diff --git a/spring-vault-dependencies/pom.xml b/spring-vault-dependencies/pom.xml index bbbb75eb..c3095dde 100644 --- a/spring-vault-dependencies/pom.xml +++ b/spring-vault-dependencies/pom.xml @@ -116,6 +116,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 c7bd4c99..da8aa30b 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