From 1711479dc0f138d8eea6320120e1375483580729 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 1 Mar 2018 15:23:00 +0100 Subject: [PATCH] Add support for GCP authentication. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now provide GCP IAM and GCP GCE authentication support for service accounts based on JSON Web Token. GCP IAM requires Google Cloud Java SDK for credential and IAM interaction. GcpIamAuthenticationOptions options = GcpIamAuthenticationOptions.builder() .role(…).credential(GoogleCredentials.getApplicationDefault()).build(); GcpIamAuthentication authentication = new GcpIamAuthentication(options, restOperations()); Closes gh-126. --- spring-vault-core/pom.xml | 22 ++ .../GcpComputeAuthentication.java | 172 ++++++++++++ .../GcpComputeAuthenticationOptions.java | 159 +++++++++++ .../authentication/GcpCredentialSupplier.java | 57 ++++ .../authentication/GcpIamAuthentication.java | 170 ++++++++++++ .../GcpIamAuthenticationOptions.java | 247 ++++++++++++++++++ .../GcpJwtAuthenticationSupport.java | 108 ++++++++ .../CubbyholeAuthenticationUnitTests.java | 10 +- .../GcpComputeAuthenticationUnitTests.java | 118 +++++++++ .../GcpIamAuthenticationUnitTests.java | 105 ++++++++ spring-vault-dependencies/pom.xml | 15 ++ src/main/asciidoc/new-features.adoc | 4 + .../asciidoc/reference/authentication.adoc | 106 ++++++++ 13 files changed, 1288 insertions(+), 5 deletions(-) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthentication.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthenticationOptions.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpCredentialSupplier.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthentication.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthenticationOptions.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpJwtAuthenticationSupport.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpComputeAuthenticationUnitTests.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpIamAuthenticationUnitTests.java diff --git a/spring-vault-core/pom.xml b/spring-vault-core/pom.xml index 4117f36f..df4377f7 100644 --- a/spring-vault-core/pom.xml +++ b/spring-vault-core/pom.xml @@ -140,6 +140,28 @@ + + com.google.apis + google-api-services-iam + true + + + com.fasterxml.jackson.core + jackson-core + + + org.apache.httpcomponents + httpclient + + + + + + com.google.auth + google-auth-library-oauth2-http + true + + diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthentication.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthentication.java new file mode 100644 index 00000000..b98f94ba --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthentication.java @@ -0,0 +1,172 @@ +/* + * Copyright 2018 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.util.LinkedHashMap; +import java.util.Map; + +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.vault.VaultException; +import org.springframework.vault.authentication.AuthenticationSteps.HttpRequest; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestOperations; + +import static org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder.get; + +/** + * GCP GCE (Google Compute Engine)-based login implementation using GCE's metadata service + * to create signed JSON Web Token. + *

+ * This authentication method uses Googles GCE's metadata service in combination with the + * default/specified service account to obtain an identity document as JWT using a HTTP + * client. Credentials and authenticity are implied from the runtime itself and are not + * required to be configured. + * + * @author Mark Paluch + * @since 2.1 + * @see GcpComputeAuthenticationOptions + * @see Auth Backend: gcp + * (IAM) + * @see Google + * Compute Engine: Verifying the Identity of Instances + */ +public class GcpComputeAuthentication extends GcpJwtAuthenticationSupport implements + ClientAuthentication, AuthenticationStepsFactory { + + public static final String COMPUTE_METADATA_URL_TEMPLATE = "http://metadata/computeMetadata/v1/instance/service-accounts/{serviceAccount}/identity" + + "?audience={audience}&format={format}"; + + private final GcpComputeAuthenticationOptions options; + + private final RestOperations googleMetadataRestOperations; + + /** + * Create a new {@link GcpComputeAuthentication} instance given + * {@link GcpComputeAuthenticationOptions} and {@link RestOperations} for Vault and + * Google API use. + * + * @param options must not be {@literal null}. + * @param vaultRestOperations must not be {@literal null}. + */ + public GcpComputeAuthentication(GcpComputeAuthenticationOptions options, + RestOperations vaultRestOperations) { + this(options, vaultRestOperations, vaultRestOperations); + } + + /** + * Create a new {@link GcpComputeAuthentication} instance given + * {@link GcpComputeAuthenticationOptions} and {@link RestOperations} for Vault and + * Google API use. + * + * @param options must not be {@literal null}. + * @param vaultRestOperations must not be {@literal null}. + * @param googleMetadataRestOperations must not be {@literal null}. + */ + public GcpComputeAuthentication(GcpComputeAuthenticationOptions options, + RestOperations vaultRestOperations, + RestOperations googleMetadataRestOperations) { + + super(vaultRestOperations); + + Assert.notNull(options, "GcpGceAuthenticationOptions must not be null"); + Assert.notNull(googleMetadataRestOperations, + "Google Metadata RestOperations must not be null"); + + this.options = options; + this.googleMetadataRestOperations = googleMetadataRestOperations; + } + + /** + * Creates a {@link AuthenticationSteps} for GCE authentication given + * {@link GcpComputeAuthenticationOptions}. + * + * @param options must not be {@literal null}. + * @return {@link AuthenticationSteps} for cubbyhole authentication. + */ + public static AuthenticationSteps createAuthenticationSteps( + GcpComputeAuthenticationOptions options) { + + Assert.notNull(options, "CubbyholeAuthenticationOptions must not be null"); + + String serviceAccount = options.getServiceAccount(); + String audience = getAudience(options.getRole()); + + HttpRequest jwtRequest = get(COMPUTE_METADATA_URL_TEMPLATE, + serviceAccount, audience, "full") // + .with(getMetadataHttpHeaders()) // + .as(String.class); + + return AuthenticationSteps.fromHttpRequest(jwtRequest) + // + .map(jwt -> createRequestBody(options.getRole(), jwt)) + .login("auth/{mount}/login", options.getPath()); + } + + @Override + public VaultToken login() throws VaultException { + + String signedJwt = signJwt(); + + return doLogin("GCP-GCE", signedJwt, this.options.getPath(), + this.options.getRole()); + } + + @Override + public AuthenticationSteps getAuthenticationSteps() { + return createAuthenticationSteps(options); + } + + protected String signJwt() { + + try { + Map urlParameters = new LinkedHashMap<>(); + urlParameters.put("serviceAccount", this.options.getServiceAccount()); + urlParameters.put("audience", getAudience(this.options.getRole())); + urlParameters.put("format", "full"); + + HttpHeaders headers = getMetadataHttpHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = googleMetadataRestOperations.exchange( + COMPUTE_METADATA_URL_TEMPLATE, HttpMethod.GET, entity, String.class, + urlParameters); + + return response.getBody(); + } + catch (HttpStatusCodeException e) { + throw new VaultException("Cannot obtain signed identity", e); + } + } + + private static HttpHeaders getMetadataHttpHeaders() { + + HttpHeaders headers = new HttpHeaders(); + + headers.set("Metadata-Flavor", "Google"); + + return headers; + } + + private static String getAudience(String role) { + return String.format("https://localhost:8200/vault/%s", role); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthenticationOptions.java new file mode 100644 index 00000000..b3c1a7af --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpComputeAuthenticationOptions.java @@ -0,0 +1,159 @@ +/* + * Copyright 2018 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 org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Authentication options for {@link GcpComputeAuthentication}. + *

+ * Authentication options provide the path, role and an optional service account + * identifier. Instances of this class are immutable once constructed. + * + * @author Mark Paluch + * @see GcpComputeAuthentication + * @see #builder() + * @since 2.1 + */ +public class GcpComputeAuthenticationOptions { + + public static final String DEFAULT_GCP_AUTHENTICATION_PATH = "gcp"; + + /** + * Path of the gcp authentication backend mount. + */ + private final String path; + + /** + * GCE service account identifier. + */ + private final String serviceAccount; + + /** + * 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. + */ + private final String role; + + private GcpComputeAuthenticationOptions(String path, String serviceAccount, + String role) { + + this.path = path; + this.serviceAccount = serviceAccount; + this.role = role; + } + + /** + * @return a new {@link GcpComputeAuthenticationOptionsBuilder}. + */ + public static GcpComputeAuthenticationOptionsBuilder builder() { + return new GcpComputeAuthenticationOptionsBuilder(); + } + + /** + * @return the path of the gcp authentication backend mount. + */ + public String getPath() { + return path; + } + + /** + * @return the GCE service account identifier. + */ + public String getServiceAccount() { + return serviceAccount; + } + + /** + * @return name of the role against which the login is being attempted. + */ + public String getRole() { + return role; + } + + /** + * Builder for {@link GcpComputeAuthenticationOptions}. + */ + public static class GcpComputeAuthenticationOptionsBuilder { + + private String path = DEFAULT_GCP_AUTHENTICATION_PATH; + + @Nullable + private String role; + + private String serviceAccount = "default"; + + GcpComputeAuthenticationOptionsBuilder() { + } + + /** + * Configure the mount path, defaults to {@literal aws}. + * + * @param path must not be empty or {@literal null}. + * @return {@code this} {@link GcpComputeAuthenticationOptionsBuilder}. + */ + public GcpComputeAuthenticationOptionsBuilder path(String path) { + + Assert.hasText(path, "Path must not be empty"); + + this.path = path; + return this; + } + + /** + * Configure the service account identifier. Uses the {@code default} service + * account if left unconfigured. + * + * @param serviceAccount must not be empty or {@literal null}. + * @return {@code this} {@link GcpComputeAuthenticationOptionsBuilder}. + */ + public GcpComputeAuthenticationOptionsBuilder serviceAccount(String serviceAccount) { + + Assert.hasText(serviceAccount, "Service account must not be null"); + + this.serviceAccount = serviceAccount; + return this; + } + + /** + * Configure the name of the role against which the login is being attempted. + * + * @param role must not be empty or {@literal null}. + * @return {@code this} {@link GcpComputeAuthenticationOptionsBuilder}. + */ + public GcpComputeAuthenticationOptionsBuilder role(String role) { + + Assert.hasText(role, "Role must not be null or empty"); + + this.role = role; + return this; + } + + /** + * Build a new {@link GcpComputeAuthenticationOptions} instance. + * + * @return a new {@link GcpComputeAuthenticationOptions}. + */ + public GcpComputeAuthenticationOptions build() { + + Assert.notNull(role, "Role must not be null"); + + return new GcpComputeAuthenticationOptions(path, serviceAccount, role); + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpCredentialSupplier.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpCredentialSupplier.java new file mode 100644 index 00000000..1a121349 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpCredentialSupplier.java @@ -0,0 +1,57 @@ +/* + * Copyright 2018 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.IOException; +import java.util.function.Supplier; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; + +/** + * Interface to obtain a {@link GoogleCredential} for GCP IAM authentication. + * Implementations are used by {@link GcpIamAuthentication}. + * + * @author Mark Paluch + * @since 2.1 + * @see GcpIamAuthentication + */ +@FunctionalInterface +public interface GcpCredentialSupplier extends Supplier { + + /** + * Exception-safe helper to get {@link GoogleCredential} from {@link #getCredential}. + * + * @return the GoogleCredential for JWT signing. + */ + @Override + default GoogleCredential get() { + + try { + return getCredential(); + } + catch (IOException e) { + throw new IllegalStateException("Cannot obtain GoogleCredential", e); + } + } + + /** + * Get a {@link GoogleCredential} for GCP IAM authentication via JWT signing. + * + * @return the {@link GoogleCredential}. + * @throws IOException if the credential lookup fails. + */ + GoogleCredential getCredential() throws IOException; +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthentication.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthentication.java new file mode 100644 index 00000000..d601b35e --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthentication.java @@ -0,0 +1,170 @@ +/* + * Copyright 2018 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.IOException; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.api.client.googleapis.apache.GoogleApacheHttpTransport; +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.iam.v1.Iam; +import com.google.api.services.iam.v1.Iam.Builder; +import com.google.api.services.iam.v1.Iam.Projects.ServiceAccounts.SignJwt; +import com.google.api.services.iam.v1.model.SignJwtRequest; +import com.google.api.services.iam.v1.model.SignJwtResponse; +import com.google.auth.oauth2.GoogleCredentials; + +import org.springframework.util.Assert; +import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestOperations; + +/** + * GCP IAM login implementation using GCP IAM service accounts to legitimate its + * authenticity via JSON Web Token. + *

+ * This authentication method uses Googles IAM API to obtain a signed token for a specific + * {@link com.google.api.client.auth.oauth2.Credential}. Project and service account + * details are obtained from a {@link GoogleCredential} that can be retrieved either from + * a JSON file or the runtime environment (GAE, GCE). + *

+ * {@link GcpIamAuthentication} uses Google Java API that uses synchronous API. + * + * @author Mark Paluch + * @since 2.1 + * @see GcpIamAuthenticationOptions + * @see HttpTransport + * @see GoogleCredential + * @see GoogleCredentials#getApplicationDefault() + * @see RestOperations + * @see Auth Backend: gcp + * (IAM) + * @see GCP: + * projects.serviceAccounts.signJwt + */ +public class GcpIamAuthentication extends GcpJwtAuthenticationSupport implements + ClientAuthentication { + + private static final JsonFactory JSON_FACTORY = new JacksonFactory(); + + private final GcpIamAuthenticationOptions options; + + private final HttpTransport httpTransport; + + private final GoogleCredential credential; + + /** + * Create a new instance of {@link GcpIamAuthentication} given + * {@link GcpIamAuthenticationOptions} and {@link RestOperations}. This constructor + * initializes {@link GoogleApacheHttpTransport} for Google API usage. + * + * @param options must not be {@literal null}. + * @param restOperations HTTP client for for Vault login, must not be {@literal null}. + * @throws GeneralSecurityException thrown by + * {@link GoogleApacheHttpTransport#newTrustedTransport()}. + * @throws IOException thrown by + * {@link GoogleApacheHttpTransport#newTrustedTransport()}. + */ + public GcpIamAuthentication(GcpIamAuthenticationOptions options, + RestOperations restOperations) throws GeneralSecurityException, IOException { + this(options, restOperations, GoogleApacheHttpTransport.newTrustedTransport()); + } + + /** + * Create a new instance of {@link GcpIamAuthentication} given + * {@link GcpIamAuthenticationOptions}, {@link RestOperations} and + * {@link HttpTransport}. + * + * @param options must not be {@literal null}. + * @param restOperations HTTP client for for Vault login, must not be {@literal null}. + * @param httpTransport HTTP client for Google API use, must not be {@literal null}. + */ + public GcpIamAuthentication(GcpIamAuthenticationOptions options, + RestOperations restOperations, HttpTransport httpTransport) { + + super(restOperations); + + Assert.notNull(options, "GcpIamAuthenticationOptions must not be null!"); + Assert.notNull(restOperations, "RestOperations must not be null!"); + Assert.notNull(httpTransport, "HttpTransport must not be null!"); + + this.options = options; + this.httpTransport = httpTransport; + this.credential = options.getCredentialSupplier().get(); + } + + @SuppressWarnings("unchecked") + @Override + public VaultToken login() throws VaultException { + + String signedJwt = signJwt(); + + return doLogin("GCP-IAM", signedJwt, this.options.getPath(), + this.options.getRole()); + } + + protected String signJwt() { + + String projectId = credential.getServiceAccountProjectId(); + String serviceAccount = credential.getServiceAccountId(); + Map jwtPayload = getJwtPayload(options, serviceAccount); + + Iam iam = new Builder(httpTransport, JSON_FACTORY, credential) + .setApplicationName("Spring Vault/" + getClass().getName()).build(); + + try { + + String payload = JSON_FACTORY.toString(jwtPayload); + SignJwtRequest request = new SignJwtRequest(); + request.setPayload(payload); + + SignJwt signJwt = iam + .projects() + .serviceAccounts() + .signJwt( + String.format("projects/%s/serviceAccounts/%s", projectId, + serviceAccount), request); + + SignJwtResponse response = signJwt.execute(); + + return response.getSignedJwt(); + } + catch (IOException e) { + throw new VaultException("Cannot sign JWT", e); + } + } + + private static Map getJwtPayload(GcpIamAuthenticationOptions options, + String serviceAccount) { + + Instant validUntil = options.getClock().instant().plus(options.getJwtValidity()); + + Map payload = new LinkedHashMap<>(); + + payload.put("sub", serviceAccount); + payload.put("aud", "vault/" + options.getRole()); + payload.put("exp", validUntil.getEpochSecond()); + + return payload; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthenticationOptions.java new file mode 100644 index 00000000..b9518430 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpIamAuthenticationOptions.java @@ -0,0 +1,247 @@ +/* + * Copyright 2018 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.Clock; +import java.time.Duration; + +import com.amazonaws.auth.AWSCredentialsProvider; +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Authentication options for {@link GcpIamAuthentication}. + *

+ * Authentication options provide the path, a {@link GcpCredentialSupplier}, role and JWT + * expiry for GCP IAM authentication. Instances of this class are immutable once + * constructed. + * + * @author Mark Paluch + * @see GcpIamAuthentication + * @see #builder() + * @since 2.1 + */ +public class GcpIamAuthenticationOptions { + + public static final String DEFAULT_GCP_AUTHENTICATION_PATH = "gcp"; + + /** + * Path of the gcp authentication backend mount. + */ + private final String path; + + private final GcpCredentialSupplier credentialSupplier; + + /** + * 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. + */ + private final String role; + + /** + * JWT validity/expiration. + */ + private final Duration jwtValidity; + + /** + * {@link Clock} to calculate JWT expiration. + */ + private final Clock clock; + + private GcpIamAuthenticationOptions(String path, + GcpCredentialSupplier credentialSupplier, String role, Duration jwtValidity, + Clock clock) { + + this.path = path; + this.credentialSupplier = credentialSupplier; + this.role = role; + this.jwtValidity = jwtValidity; + this.clock = clock; + } + + /** + * @return a new {@link GcpIamAuthenticationOptionsBuilder}. + */ + public static GcpIamAuthenticationOptionsBuilder builder() { + return new GcpIamAuthenticationOptionsBuilder(); + } + + /** + * @return the path of the gcp authentication backend mount. + */ + public String getPath() { + return path; + } + + /** + * @return the gcp {@link Credential} supplier. + */ + public GcpCredentialSupplier getCredentialSupplier() { + return credentialSupplier; + } + + /** + * @return name of the role against which the login is being attempted. + */ + public String getRole() { + return role; + } + + /** + * @return {@link Duration} of the JWT to generate. + */ + public Duration getJwtValidity() { + return jwtValidity; + } + + /** + * @return {@link Clock} used to calculate epoch seconds until the JWT expires. + */ + public Clock getClock() { + return clock; + } + + /** + * Builder for {@link GcpIamAuthenticationOptions}. + */ + public static class GcpIamAuthenticationOptionsBuilder { + + private String path = DEFAULT_GCP_AUTHENTICATION_PATH; + + @Nullable + private String role; + + @Nullable + private GcpCredentialSupplier credentialSupplier; + + private Duration jwtValidity = Duration.ofMinutes(15); + + private Clock clock = Clock.systemDefaultZone(); + + GcpIamAuthenticationOptionsBuilder() { + } + + /** + * Configure the mount path, defaults to {@literal aws}. + * + * @param path must not be empty or {@literal null}. + * @return {@code this} {@link GcpIamAuthenticationOptionsBuilder}. + */ + public GcpIamAuthenticationOptionsBuilder path(String path) { + + Assert.hasText(path, "Path must not be empty"); + + this.path = path; + return this; + } + + /** + * Configure static Google credentials, required to create a signed JWT. Either + * use static credentials or provide a + * {@link #credentialSupplier(GcpCredentialSupplier) credentials provider}. + * + * @param credential must not be {@literal null}. + * @return {@code this} {@link GcpIamAuthenticationOptionsBuilder}. + * @see #credentialSupplier(GcpCredentialSupplier) + */ + public GcpIamAuthenticationOptionsBuilder credential(GoogleCredential credential) { + + Assert.notNull(credential, "Credential must not be null"); + + return credentialSupplier(() -> credential); + } + + /** + * Configure an {@link AWSCredentialsProvider}, required to create a signed JWT. + * Alternatively, configure static {@link #credential(GoogleCredential) + * credentials}. + * + * @param credentialSupplier must not be {@literal null}. + * @return {@code this} {@link GcpIamAuthenticationOptionsBuilder}. + * @see #credential(GoogleCredential) + */ + public GcpIamAuthenticationOptionsBuilder credentialSupplier( + GcpCredentialSupplier credentialSupplier) { + + Assert.notNull(credentialSupplier, "GcpCredentialSupplier must not be null"); + + this.credentialSupplier = credentialSupplier; + return this; + } + + /** + * Configure the name of the role against which the login is being attempted. + * + * @param role must not be empty or {@literal null}. + * @return {@code this} {@link GcpIamAuthenticationOptionsBuilder}. + */ + public GcpIamAuthenticationOptionsBuilder role(String role) { + + Assert.hasText(role, "Role must not be null or empty"); + + this.role = role; + return this; + } + + /** + * Configure the {@link Duration} for the JWT expiration. This defaults to 15 + * minutes and cannot be more than a hour. + * + * @param jwtValidity must not be {@literal null}. + * @return {@code this} {@link GcpIamAuthenticationOptionsBuilder}. + */ + public GcpIamAuthenticationOptionsBuilder jwtValidity(Duration jwtValidity) { + + Assert.hasText(role, "JWT validity duration must not be null"); + + this.jwtValidity = jwtValidity; + return this; + } + + /** + * Configure the {@link Clock} used to calculate epoch seconds until the JWT + * expiration. + * + * @param clock must not be {@literal null}. + * @return {@code this} {@link GcpIamAuthenticationOptionsBuilder}. + */ + public GcpIamAuthenticationOptionsBuilder clock(Clock clock) { + + Assert.hasText(role, "Clock must not be null"); + + this.clock = clock; + return this; + } + + /** + * Build a new {@link GcpIamAuthenticationOptions} instance. + * + * @return a new {@link GcpIamAuthenticationOptions}. + */ + public GcpIamAuthenticationOptions build() { + + Assert.notNull(credentialSupplier, "GcpCredentialSupplier must not be null"); + Assert.notNull(role, "Role must not be null"); + + return new GcpIamAuthenticationOptions(path, credentialSupplier, role, + jwtValidity, clock); + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpJwtAuthenticationSupport.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpJwtAuthenticationSupport.java new file mode 100644 index 00000000..ac751cb8 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GcpJwtAuthenticationSupport.java @@ -0,0 +1,108 @@ +/* + * Copyright 2018 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.util.HashMap; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.util.Assert; +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; + +/** + * Base class for GCP JWT-based authentication. Used by framework components. + * + * @author Mark Paluch + * @since 2.1 + */ +public abstract class GcpJwtAuthenticationSupport { + + private static final Log logger = LogFactory + .getLog(GcpJwtAuthenticationSupport.class); + + private final RestOperations restOperations; + + GcpJwtAuthenticationSupport(RestOperations restOperations) { + + Assert.notNull(restOperations, "Vault RestOperations must not be null"); + + this.restOperations = restOperations; + } + + /** + * Perform the actual Vault login given {@code signedJwt}. + * + * @param authenticationName authentication name for logging. + * @param signedJwt the JSON web token. + * @param path GCP authentication mount path. + * @param role Vault role. + * @return the {@link VaultToken}. + */ + VaultToken doLogin(String authenticationName, String signedJwt, String path, + String role) { + + Map login = createRequestBody(role, signedJwt); + + try { + + VaultResponse response = this.restOperations.postForObject( + "auth/{mount}/login", login, VaultResponse.class, path); + + 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 %s authentication for user id %s", + authenticationName, metadata.get("service_account_email"))); + } + else { + logger.debug("Login successful using " + authenticationName + + " authentication"); + } + } + + return LoginTokenUtil.from(response.getAuth()); + } + catch (HttpStatusCodeException e) { + throw new VaultException(String.format("Cannot login using %s: %s", + authenticationName, + VaultResponses.getError(e.getResponseBodyAsString()))); + } + } + + static Map createRequestBody(String role, String signedJwt) { + + Map login = new HashMap<>(); + + login.put("role", role); + login.put("jwt", signedJwt); + + return login; + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/CubbyholeAuthenticationUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/CubbyholeAuthenticationUnitTests.java index 199ffcd3..258d9d4e 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/authentication/CubbyholeAuthenticationUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/CubbyholeAuthenticationUnitTests.java @@ -50,7 +50,7 @@ public class CubbyholeAuthenticationUnitTests { private MockRestServiceServer mockRest; @Before - public void before() throws Exception { + public void before() { RestTemplate restTemplate = new RestTemplate(); restTemplate.setUriTemplateHandler(new PrefixAwareUriTemplateHandler()); @@ -132,7 +132,7 @@ public class CubbyholeAuthenticationUnitTests { } @Test - public void shouldLoginUsingStoredLogin() throws Exception { + public void shouldLoginUsingStoredLogin() { mockRest.expect(requestTo("/cubbyhole/token")).andExpect(method(HttpMethod.GET)) .andExpect(header(VaultHttpHeaders.VAULT_TOKEN, "hello")) @@ -153,7 +153,7 @@ public class CubbyholeAuthenticationUnitTests { } @Test - public void shouldRetrieveRenewabulityUsingStoredLogin() throws Exception { + public void shouldRetrieveRenewabilityUsingStoredLogin() { mockRest.expect(requestTo("/cubbyhole/token")).andExpect(method(HttpMethod.GET)) .andExpect(header(VaultHttpHeaders.VAULT_TOKEN, "hello")) @@ -185,7 +185,7 @@ public class CubbyholeAuthenticationUnitTests { } @Test - public void shouldFailUsingStoredLoginNoData() throws Exception { + public void shouldFailUsingStoredLoginNoData() { mockRest.expect(requestTo("/cubbyhole/token")).andExpect(method(HttpMethod.GET)) .andExpect(header(VaultHttpHeaders.VAULT_TOKEN, "hello")) @@ -208,7 +208,7 @@ public class CubbyholeAuthenticationUnitTests { } @Test - public void shouldFailUsingStoredMultipleEntries() throws Exception { + public void shouldFailUsingStoredMultipleEntries() { mockRest.expect(requestTo("/cubbyhole/token")).andExpect(method(HttpMethod.GET)) .andExpect(header(VaultHttpHeaders.VAULT_TOKEN, "hello")) diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpComputeAuthenticationUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpComputeAuthenticationUnitTests.java new file mode 100644 index 00000000..ac119f03 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpComputeAuthenticationUnitTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2018 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 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.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 tests for {@link GcpComputeAuthentication}. + * + * @author Mark Paluch + */ +public class GcpComputeAuthenticationUnitTests { + + private RestTemplate restTemplate; + private MockRestServiceServer mockRest; + + @Before + public void before() { + + RestTemplate restTemplate = new RestTemplate(); + + this.mockRest = MockRestServiceServer.createServer(restTemplate); + this.restTemplate = restTemplate; + } + + private void setupMocks() { + + mockRest.expect( + requestTo("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://localhost:8200/vault/dev-role&format=full")) + .andExpect(method(HttpMethod.GET)) + .andRespond( + withSuccess().contentType(MediaType.TEXT_PLAIN).body("my-jwt")); + + mockRest.expect(requestTo("/auth/gcp/login")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.role").value("dev-role")) + .andExpect(jsonPath("$.jwt").value("my-jwt")) + .andRespond( + withSuccess() + .contentType(MediaType.APPLICATION_JSON) + .body("{" + + "\"auth\":{\"client_token\":\"my-token\", \"renewable\": true, \"lease_duration\": 10}" + + "}")); + } + + @Test + public void shouldLogin() { + + setupMocks(); + + GcpComputeAuthenticationOptions options = GcpComputeAuthenticationOptions + .builder().role("dev-role").build(); + + GcpComputeAuthentication authentication = new GcpComputeAuthentication(options, + restTemplate); + + VaultToken login = authentication.login(); + + assertThat(login).isInstanceOf(LoginToken.class); + assertThat(login.getToken()).isEqualTo("my-token"); + + LoginToken loginToken = (LoginToken) login; + assertThat(loginToken.isRenewable()).isTrue(); + assertThat(loginToken.getLeaseDuration()).isEqualTo(Duration.ofSeconds(10)); + } + + @Test + public void shouldLoginWithAuthenticationSteps() { + + setupMocks(); + + GcpComputeAuthenticationOptions options = GcpComputeAuthenticationOptions + .builder().role("dev-role").build(); + + GcpComputeAuthentication authentication = new GcpComputeAuthentication(options, + restTemplate); + + AuthenticationStepsExecutor executor = new AuthenticationStepsExecutor( + authentication.getAuthenticationSteps(), restTemplate); + + VaultToken login = executor.login(); + + assertThat(login).isInstanceOf(LoginToken.class); + assertThat(login.getToken()).isEqualTo("my-token"); + + LoginToken loginToken = (LoginToken) login; + assertThat(loginToken.isRenewable()).isTrue(); + assertThat(loginToken.getLeaseDuration()).isEqualTo(Duration.ofSeconds(10)); + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpIamAuthenticationUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpIamAuthenticationUnitTests.java new file mode 100644 index 00000000..c3e9c5aa --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/GcpIamAuthenticationUnitTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2018 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.security.PrivateKey; +import java.time.Duration; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential.Builder; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +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.PrefixAwareUriTemplateHandler; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +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 tests for {@link GcpIamAuthentication}. + * + * @author Mark Paluch + */ +public class GcpIamAuthenticationUnitTests { + + private RestTemplate restTemplate; + private MockRestServiceServer mockRest; + private MockHttpTransport mockHttpTransport; + + @Before + public void before() { + + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setUriTemplateHandler(new PrefixAwareUriTemplateHandler()); + + this.mockRest = MockRestServiceServer.createServer(restTemplate); + this.restTemplate = restTemplate; + } + + @Test + public void shouldLogin() { + + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.setContent("{\"keyId\":\"keyid\", \"signedJwt\":\"my-jwt\"}"); + + mockHttpTransport = new MockHttpTransport.Builder().setLowLevelHttpResponse( + response).build(); + + mockRest.expect(requestTo("/auth/gcp/login")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.role").value("dev-role")) + .andExpect(jsonPath("$.jwt").value("my-jwt")) + .andRespond( + withSuccess() + .contentType(MediaType.APPLICATION_JSON) + .body("{" + + "\"auth\":{\"client_token\":\"my-token\", \"renewable\": true, \"lease_duration\": 10}" + + "}")); + + PrivateKey privateKeyMock = mock(PrivateKey.class); + GoogleCredential credential = new Builder().setServiceAccountId("hello@world") + .setServiceAccountProjectId("foobar") + .setServiceAccountPrivateKey(privateKeyMock) + .setServiceAccountPrivateKeyId("key-id").build(); + credential.setAccessToken("foobar"); + + GcpIamAuthenticationOptions options = GcpIamAuthenticationOptions.builder() + .role("dev-role").credential(credential).build(); + GcpIamAuthentication authentication = new GcpIamAuthentication(options, + restTemplate, mockHttpTransport); + + VaultToken login = authentication.login(); + + assertThat(login).isInstanceOf(LoginToken.class); + assertThat(login.getToken()).isEqualTo("my-token"); + + LoginToken loginToken = (LoginToken) login; + assertThat(loginToken.isRenewable()).isTrue(); + assertThat(loginToken.getLeaseDuration()).isEqualTo(Duration.ofSeconds(10)); + } +} diff --git a/spring-vault-dependencies/pom.xml b/spring-vault-dependencies/pom.xml index 1801b163..d4af056d 100644 --- a/spring-vault-dependencies/pom.xml +++ b/spring-vault-dependencies/pom.xml @@ -124,6 +124,21 @@ true + + + com.google.apis + google-api-services-iam + v1-rev233-1.23.0 + true + + + + com.google.auth + google-auth-library-oauth2-http + 0.9.0 + true + + diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 581d2614..ff8b8856 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -1,6 +1,10 @@ [[new-features]] == New & Noteworthy +[[new-features.2-1-0]] +=== What's new in Spring Vault 2.1 +* <> and <> authentication. + [[new-features.2-0-0]] === What's new in Spring Vault 2.0 diff --git a/src/main/asciidoc/reference/authentication.adoc b/src/main/asciidoc/reference/authentication.adoc index e9641b70..b7ae16bd 100644 --- a/src/main/asciidoc/reference/authentication.adoc +++ b/src/main/asciidoc/reference/authentication.adoc @@ -375,6 +375,112 @@ 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.gcpgce]] +== GCP-GCE authentication + +The https://www.vaultproject.io/docs/auth/gcp.html[gcp] +auth backend allows Vault login by using existing GCP (Google Cloud Platform) IAM and GCE credentials. + +GCP GCE (Google Compute Engine) authentication creates a signature in the form of a +JSON Web Token (JWT) for a service account. A JWT for a Compute Engine instance +is obtained from the GCE metadata service using https://cloud.google.com/compute/docs/instances/verifying-instance-identity[Instance identification]. +This API creates a JSON Web Token that can be used to confirm the instance identity. + +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 GCP as a Trusted Third Party and uses the +cryptographically signed dynamic metadata information that uniquely +represents each GCP service account. + +==== +[source,java] +---- +@Configuration +class AppConfig extends AbstractVaultConfiguration { + + // … + + @Override + public ClientAuthentication clientAuthentication() { + + GcpComputeAuthenticationOptions options = GcpComputeAuthenticationOptions.builder() + .role(…).build(); + + GcpComputeAuthentication authentication = new GcpComputeAuthentication(options, + restOperations()); + } + + // … +} +---- +==== + +`GcpIamAuthenticationOptions` requires the Google Cloud Java SDK dependency +(`com.google.apis:google-api-services-iam` and `com.google.auth:google-auth-library-oauth2-http`) +as the authentication implementation uses Google APIs for credentials and JWT signing. + +You can configure the authentication via `GcpIamAuthenticationOptions`. + +See also: + +* https://www.vaultproject.io/docs/auth/gcp.html[Vault Documentation: Using the GCP auth backend] +* https://cloud.google.com/compute/docs/instances/verifying-instance-identity[GCP Documentation: Verifying the Identity of Instances] + +[[vault.authentication.gcpiam]] +== GCP-IAM authentication + +The https://www.vaultproject.io/docs/auth/gcp.html[gcp] +auth backend allows Vault login by using existing GCP (Google Cloud Platform) IAM and GCE credentials. + +GCP IAM authentication creates a signature in the form of a JSON Web Token (JWT) +for a service account. A JWT for a service account is obtained by +calling GCP IAM's https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signJwt[`projects.serviceAccounts.signJwt`] API. The caller authenticates against GCP IAM +and proves thereby its identity. This Vault backend treats GCP as a Trusted Third Party. + +IAM credentials can be obtained from either the runtime environment +or supplied externally as e.g. JSON. JSON is the preferred form as it +carries the project id and service account identifier required for calling +``projects.serviceAccounts.signJwt``. + +==== +[source,java] +---- +@Configuration +class AppConfig extends AbstractVaultConfiguration { + + // … + + @Override + public ClientAuthentication clientAuthentication() { + + GcpIamAuthenticationOptions options = GcpIamAuthenticationOptions.builder() + .role(…).credential(GoogleCredentials.getApplicationDefault()).build(); + + GcpIamAuthentication authentication = new GcpIamAuthentication(options, + restOperations()); + } + + // … +} +---- +==== + +`GcpIamAuthenticationOptions` requires the Google Cloud Java SDK dependency +(`com.google.apis:google-api-services-iam` and `com.google.auth:google-auth-library-oauth2-http`) +as the authentication implementation uses Google APIs for credentials and JWT signing. + +You can configure the authentication via `GcpIamAuthenticationOptions`. + +NOTE: Google credentials require an OAuth 2 token maintaining the token lifecycle. All API +is synchronous therefore, `GcpIamAuthentication` does not support `AuthenticationSteps` which is +required for reactive usage. + +See also: + +* https://www.vaultproject.io/docs/auth/gcp.html[Vault Documentation: Using the GCP auth backend] +* https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signJwt[GCP Documentation: projects.serviceAccounts.signJwt][[vault.authentication.gcpiam]] + [[vault.authentication.clientcert]] == TLS certificate authentication