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