Add support for IMDSv2 on EC2 instances.

Closes gh-865
This commit is contained in:
Mark Paluch
2024-06-06 09:51:58 +02:00
parent 3cf4e01f84
commit dac9e36c37
8 changed files with 330 additions and 30 deletions

View File

@@ -342,6 +342,27 @@ public class AuthenticationSteps {
return new HttpRequestBuilder(HttpMethod.POST, uri);
}
/**
* Builder entry point to {@code PUT} to {@code uriTemplate}.
* @param uriTemplate must not be {@literal null} or empty.
* @param uriVariables the variables to expand the template.
* @return a new {@link HttpRequestBuilder}.
* @since 3.2
*/
public static HttpRequestBuilder put(String uriTemplate, String... uriVariables) {
return new HttpRequestBuilder(HttpMethod.PUT, uriTemplate, uriVariables);
}
/**
* Builder entry point to {@code PUT} to {@code uri}.
* @param uri must not be {@literal null}.
* @return a new {@link HttpRequestBuilder}.
* @since 3.2
*/
public static HttpRequestBuilder put(URI uri) {
return new HttpRequestBuilder(HttpMethod.PUT, uri);
}
/**
* Builder entry point to use {@link HttpMethod} for {@code uriTemplate}.
* @param uriTemplate must not be {@literal null} or empty.

View File

@@ -19,6 +19,7 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -48,7 +49,7 @@ import org.springframework.web.client.RestOperations;
*/
public class AuthenticationStepsExecutor implements ClientAuthentication {
private static final Log logger = LogFactory.getLog(AppIdAuthentication.class);
private static final Log logger = LogFactory.getLog(AuthenticationStepsExecutor.class);
private final AuthenticationSteps chain;
@@ -166,9 +167,14 @@ public class AuthenticationStepsExecutor implements ClientAuthentication {
}
private static HttpEntity<?> getEntity(@Nullable HttpEntity<?> entity, @Nullable Object state) {
static HttpEntity<?> getEntity(@Nullable HttpEntity<?> entity, @Nullable Object state) {
if (entity == null) {
if (state instanceof HttpHeaders headers) {
return new HttpEntity<>(headers);
}
return state == null ? HttpEntity.EMPTY : new HttpEntity<>(state);
}

View File

@@ -29,6 +29,7 @@ import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.vault.VaultException;
@@ -161,7 +162,7 @@ public class AuthenticationStepsOperator implements VaultTokenSupplier {
private Mono<Object> doHttpRequest(HttpRequestNode<Object> step, Object state) {
HttpRequest<Object> definition = step.getDefinition();
HttpEntity<?> entity = getEntity(definition.getEntity(), state);
HttpEntity<?> entity = AuthenticationStepsExecutor.getEntity(definition.getEntity(), state);
RequestBodySpec spec;
if (definition.getUri() == null) {
@@ -184,19 +185,6 @@ public class AuthenticationStepsOperator implements VaultTokenSupplier {
return spec.retrieve().bodyToMono(definition.getResponseType());
}
private static HttpEntity<?> getEntity(@Nullable HttpEntity<?> entity, @Nullable Object state) {
if (entity == null) {
return state == null ? HttpEntity.EMPTY : new HttpEntity<>(state);
}
if (entity.getBody() == null && state != null) {
return new HttpEntity<>(state, entity.getHeaders());
}
return entity;
}
private static Object doMapStep(MapStep<Object, Object> o, Object state) {
return o.apply(state);
}

View File

@@ -16,6 +16,7 @@
package org.springframework.vault.authentication;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
@@ -24,12 +25,17 @@ import java.util.function.Supplier;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.vault.VaultException;
import org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultToken;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
@@ -52,6 +58,10 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio
private static final char[] EMPTY = new char[0];
private static final String METADATA_TOKEN_TTL_HEADER = "X-aws-ec2-metadata-token-ttl-seconds";
private static final String METADATA_TOKEN_HEADER = "X-aws-ec2-metadata-token";
private final AwsEc2AuthenticationOptions options;
private final RestOperations vaultRestOperations;
@@ -107,13 +117,34 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio
protected static AuthenticationSteps createAuthenticationSteps(AwsEc2AuthenticationOptions options,
AtomicReference<char[]> nonce, Supplier<char[]> nonceSupplier) {
return AuthenticationSteps
.fromHttpRequest(HttpRequestBuilder.get(options.getIdentityDocumentUri().toString()).as(String.class)) //
AuthenticationSteps.HttpRequest<String> identityRequest = HttpRequestBuilder
.get(options.getIdentityDocumentUri().toString())
.as(String.class);
AuthenticationSteps.Node<String> identity;
if (options.getVersion() == AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2) {
identity = AuthenticationSteps
.fromHttpRequest(HttpRequestBuilder.put(options.getMetadataTokenRequestUri())
.with(createTokenRequestHeaders(options))
.as(String.class))
.map(it -> {
HttpHeaders headers = new HttpHeaders();
headers.add(METADATA_TOKEN_HEADER, it);
return headers;
})
.request(identityRequest);
}
else {
identity = AuthenticationSteps.fromHttpRequest(identityRequest);
}
return identity //
.map(pkcs7 -> pkcs7.replaceAll("\\r", "")) //
.map(pkcs7 -> pkcs7.replaceAll("\\n", "")) //
.map(pkcs7 -> {
Map<String, String> login = new HashMap<>();
Map<String, String> login = new LinkedHashMap<>();
if (StringUtils.hasText(options.getRole())) {
login.put("role", options.getRole());
@@ -175,6 +206,11 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio
protected Map<String, String> getEc2Login() {
Map<String, String> login = new HashMap<>();
HttpHeaders headers = new HttpHeaders();
if (options.getVersion() == AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2) {
headers.add(METADATA_TOKEN_HEADER, createIMDSv2Token());
}
if (StringUtils.hasText(this.options.getRole())) {
login.put("role", this.options.getRole());
@@ -187,8 +223,15 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio
login.put("nonce", new String(this.nonce.get()));
try {
String pkcs7 = this.awsMetadataRestOperations.getForObject(this.options.getIdentityDocumentUri(),
String.class);
HttpEntity<Object> entity = new HttpEntity<>(headers);
ResponseEntity<String> exchange = this.awsMetadataRestOperations
.exchange(this.options.getIdentityDocumentUri(), HttpMethod.GET, entity, String.class);
if (!exchange.getStatusCode().is2xxSuccessful()) {
throw new HttpClientErrorException(exchange.getStatusCode());
}
String pkcs7 = exchange.getBody();
if (StringUtils.hasText(pkcs7)) {
login.put("pkcs7", pkcs7.replaceAll("\\r", "").replaceAll("\\n", ""));
}
@@ -201,6 +244,26 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio
}
}
private String createIMDSv2Token() {
try {
HttpEntity<Object> entity = new HttpEntity<>(createTokenRequestHeaders(this.options));
ResponseEntity<String> exchange = this.awsMetadataRestOperations
.exchange(this.options.getMetadataTokenRequestUri(), HttpMethod.PUT, entity, String.class);
if (!exchange.getStatusCode().is2xxSuccessful()) {
throw new HttpClientErrorException(exchange.getStatusCode());
}
return exchange.getBody();
}
catch (RestClientException e) {
throw new VaultLoginException(
String.format("Cannot obtain IMDSv2 Token from %s", this.options.getMetadataTokenRequestUri()), e);
}
}
protected char[] createNonce() {
return doCreateNonce(this.options);
}
@@ -209,4 +272,10 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio
return options.getNonce().getValue();
}
private static HttpHeaders createTokenRequestHeaders(AwsEc2AuthenticationOptions options) {
HttpHeaders tokenRequestHeaders = new HttpHeaders();
tokenRequestHeaders.add(METADATA_TOKEN_TTL_HEADER, String.valueOf(options.getMetadataTokenTtl().toSeconds()));
return tokenRequestHeaders;
}
}

View File

@@ -16,6 +16,7 @@
package org.springframework.vault.authentication;
import java.net.URI;
import java.time.Duration;
import java.util.Arrays;
import java.util.UUID;
@@ -38,6 +39,11 @@ public class AwsEc2AuthenticationOptions {
public static final URI DEFAULT_PKCS7_IDENTITY_DOCUMENT_URI = URI
.create("http://169.254.169.254/latest/dynamic/instance-identity/pkcs7");
/**
* @since 3.2
*/
public static final URI DEFAULT_IMDSV2_TOKEN_URI = URI.create("http://169.254.169.254/latest/api/token");
public static final String DEFAULT_AWS_AUTHENTICATION_PATH = "aws-ec2";
/**
@@ -68,16 +74,39 @@ public class AwsEc2AuthenticationOptions {
*/
private final Nonce nonce;
/**
* IMDSv2 token TTL.
* @since 3.2
*/
private final Duration metadataTokenTtl;
/**
* {@link URI} to request a token for the AWS EC2 Instance Metadata service v2.
* @since 3.2
*/
private final URI metadataTokenRequestUri;
/**
* Metadata service version.
* @since 3.2
*/
private final InstanceMetadataServiceVersion version;
private AwsEc2AuthenticationOptions() {
this(DEFAULT_AWS_AUTHENTICATION_PATH, DEFAULT_PKCS7_IDENTITY_DOCUMENT_URI, "", Nonce.generated());
this(DEFAULT_AWS_AUTHENTICATION_PATH, DEFAULT_PKCS7_IDENTITY_DOCUMENT_URI, "", Nonce.generated(),
Duration.ofMinutes(1), DEFAULT_IMDSV2_TOKEN_URI, InstanceMetadataServiceVersion.V1);
}
private AwsEc2AuthenticationOptions(String path, URI identityDocumentUri, @Nullable String role, Nonce nonce) {
private AwsEc2AuthenticationOptions(String path, URI identityDocumentUri, @Nullable String role, Nonce nonce,
Duration metadataTokenTtl, URI metadataTokenRequestUri, InstanceMetadataServiceVersion version) {
this.path = path;
this.identityDocumentUri = identityDocumentUri;
this.role = role;
this.nonce = nonce;
this.metadataTokenTtl = metadataTokenTtl;
this.metadataTokenRequestUri = metadataTokenRequestUri;
this.version = version;
}
/**
@@ -116,6 +145,30 @@ public class AwsEc2AuthenticationOptions {
return this.nonce;
}
/**
* @return the configured {@link InstanceMetadataServiceVersion}.
* @since 3.2
*/
public InstanceMetadataServiceVersion getVersion() {
return version;
}
/**
* @return the configured IMDSv2 token TTL.
* @since 3.2
*/
public Duration getMetadataTokenTtl() {
return metadataTokenTtl;
}
/**
* @return the {@link URI} to the AWS EC2 Metadata Service to obtain IMDSv2 tokens.
* @since 3.2
*/
public URI getMetadataTokenRequestUri() {
return this.metadataTokenRequestUri;
}
/**
* Builder for {@link AwsEc2AuthenticationOptionsBuilder}.
*/
@@ -130,6 +183,12 @@ public class AwsEc2AuthenticationOptions {
private Nonce nonce = Nonce.generated();
private Duration metadataTokenTtl = Duration.ofMinutes(1);
private URI metadataTokenRequestUri = DEFAULT_IMDSV2_TOKEN_URI;
private InstanceMetadataServiceVersion version = InstanceMetadataServiceVersion.V1;
AwsEc2AuthenticationOptionsBuilder() {
}
@@ -188,6 +247,51 @@ public class AwsEc2AuthenticationOptions {
return this;
}
/**
* Configure the Instance Service Metadata v2 Token TTL. Defaults to 1 minute.
* @param ttl must not be {@literal null}.
* @return {@code this} {@link AwsEc2AuthenticationOptionsBuilder}.
* @since 3.2
*/
public AwsEc2AuthenticationOptionsBuilder metadataTokenTtl(Duration ttl) {
Assert.notNull(ttl, "Duration must not be null");
Assert.isTrue(!ttl.isNegative() && !ttl.isZero(), "Duration must not be zero or negative");
this.metadataTokenTtl = ttl;
return this;
}
/**
* Configure the Identity Metadata token request {@link URI}.
* @param metadataTokenRequestUri must not be {@literal null}.
* @return {@code this} {@link AwsEc2AuthenticationOptionsBuilder}.
* @since 3.2
* @see #DEFAULT_IMDSV2_TOKEN_URI
*/
public AwsEc2AuthenticationOptionsBuilder metadataTokenRequestUri(URI metadataTokenRequestUri) {
Assert.notNull(metadataTokenRequestUri, "Metadata token request URI must not be null");
this.metadataTokenRequestUri = metadataTokenRequestUri;
return this;
}
/**
* Configure the Instance Service Metadata {@link InstanceMetadataServiceVersion
* version}. Defaults to {@link InstanceMetadataServiceVersion#V1}.
* @param version must not be {@literal null}.
* @return {@code this} {@link AwsEc2AuthenticationOptionsBuilder}.
* @since 3.2
*/
public AwsEc2AuthenticationOptionsBuilder version(InstanceMetadataServiceVersion version) {
Assert.notNull(version, "Version must not be null");
this.version = version;
return this;
}
/**
* Build a new {@link AwsEc2AuthenticationOptions} instance.
* @return a new {@link AwsEc2AuthenticationOptions}.
@@ -196,7 +300,8 @@ public class AwsEc2AuthenticationOptions {
Assert.notNull(this.identityDocumentUri, "IdentityDocumentUri must not be null");
return new AwsEc2AuthenticationOptions(this.path, this.identityDocumentUri, this.role, this.nonce);
return new AwsEc2AuthenticationOptions(this.path, this.identityDocumentUri, this.role, this.nonce,
this.metadataTokenTtl, this.metadataTokenRequestUri, this.version);
}
}
@@ -258,4 +363,24 @@ public class AwsEc2AuthenticationOptions {
}
/**
* Enumeration for the Instance metadata service version.
*
* @since 3.2
*/
public enum InstanceMetadataServiceVersion {
/**
* Request/Response (default) oriented version 1.
*/
V1,
/**
* Session-oriented version 2.
*/
V2;
}
}

View File

@@ -15,6 +15,9 @@
*/
package org.springframework.vault.authentication;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder.*;
import java.io.IOException;
import java.io.InputStream;
@@ -24,6 +27,7 @@ import reactor.test.StepVerifier;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -37,9 +41,6 @@ import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultToken;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder.post;
/**
* Unit tests for {@link AuthenticationStepsOperator}.
*
@@ -123,6 +124,34 @@ class AuthenticationStepsOperatorUnitTests {
.verifyComplete();
}
@Test
void headersShouldBeConsideredForHttpRequest() {
ClientHttpRequest request = new MockClientHttpRequest(HttpMethod.POST, "/auth/cert/login");
MockClientHttpResponse response = new MockClientHttpResponse(HttpStatus.OK);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
response.setBody(
"{" + "\"auth\":{\"client_token\":\"my-token\", \"renewable\": true, \"lease_duration\": 10}" + "}");
ClientHttpConnector connector = (method, uri, fn) -> {
return fn.apply(request).doOnSuccess(unused -> {
assertThat(request.getHeaders().get("a")).contains("b");
}).then(Mono.just(response));
};
WebClient webClient = WebClient.builder().clientConnector(connector).build();
HttpHeaders headers = new HttpHeaders();
headers.add("a", "b");
AuthenticationSteps steps = AuthenticationSteps.fromValue(headers)
.login(post("/auth/{path}/login", "cert").as(VaultResponse.class));
login(steps, webClient).as(StepVerifier::create) //
.expectNext(VaultToken.of("my-token")) //
.verifyComplete();
}
@Test
void justLoginRequestShouldLogin() {

View File

@@ -19,12 +19,22 @@ import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import org.checkerframework.checker.units.qual.A;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.mock.http.client.reactive.MockClientHttpRequest;
import org.springframework.mock.http.client.reactive.MockClientHttpResponse;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.vault.VaultException;
import org.springframework.vault.authentication.AwsEc2AuthenticationOptions.Nonce;
import org.springframework.vault.client.VaultClients;
@@ -33,9 +43,7 @@ import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
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.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@@ -72,6 +80,57 @@ class AwsEc2AuthenticationUnitTests {
assertThat(authentication.getEc2Login()).containsEntry("pkcs7", "Hello, world").containsKey("nonce").hasSize(2);
}
@Test
void shouldObtainIdentityDocumentV2() {
AwsEc2AuthenticationOptions options = AwsEc2AuthenticationOptions.builder()
.version(AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2)
.build();
this.mockRest.expect(requestTo("http://169.254.169.254/latest/api/token")) //
.andExpect(method(HttpMethod.PUT)) //
.andExpect(header("X-aws-ec2-metadata-token-ttl-seconds", "60")) //
.andRespond(withSuccess().body("my-token"));
this.mockRest.expect(requestTo("http://169.254.169.254/latest/dynamic/instance-identity/pkcs7")) //
.andExpect(method(HttpMethod.GET)) //
.andExpect(header("X-aws-ec2-metadata-token", "my-token")) //
.andRespond(withSuccess().body("Hello, world"));
AwsEc2Authentication authentication = new AwsEc2Authentication(options, this.restTemplate, this.restTemplate);
assertThat(authentication.getEc2Login()).containsEntry("pkcs7", "Hello, world").containsKey("nonce").hasSize(2);
}
@Test
void shouldObtainIdentityDocumentWithImperativeAuthenticationStepsV2() {
AwsEc2AuthenticationOptions options = AwsEc2AuthenticationOptions.builder()
.version(AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2)
.build();
this.mockRest.expect(requestTo("http://169.254.169.254/latest/api/token")) //
.andExpect(method(HttpMethod.PUT)) //
.andExpect(header("X-aws-ec2-metadata-token-ttl-seconds", "60")) //
.andRespond(withSuccess().body("my-token"));
this.mockRest.expect(requestTo("http://169.254.169.254/latest/dynamic/instance-identity/pkcs7")) //
.andExpect(method(HttpMethod.GET)) //
.andExpect(header("X-aws-ec2-metadata-token", "my-token")) //
.andRespond(withSuccess().body("Hello, world"));
this.mockRest.expect(requestTo("/auth/aws-ec2/login"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.pkcs7").value("Hello, world"))
.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
.body("{" + "\"auth\":{\"client_token\":\"my-token\", \"lease_duration\":20}" + "}"));
AuthenticationSteps steps = AwsEc2Authentication.createAuthenticationSteps(options);
AuthenticationStepsExecutor executor = new AuthenticationStepsExecutor(steps, this.restTemplate);
assertThat(executor.login()).isEqualTo(LoginToken.of("my-token"));
}
@Test
void shouldCleanUpIdentityResponse() {

View File

@@ -306,6 +306,9 @@ further investigation.
The nonce is kept in memory and is lost during application restart.
Since Spring Vault 3.2, AWS-EC2 authentication supports request/response
(IMDSv1) metadata retrieval and the session-based variant (IMDSv2).
AWS-EC2 authentication roles are optional and default to the AMI.
You can configure the authentication role by setting
it in `AwsEc2AuthenticationOptions`.