Add support for PCF authentication.

We now support PCF authentication leveraging PCF's App and Container Identity Assurance.

Instance certificate and key default to CF_INSTANCE_CERT respective CF_INSTANCE_KEY environment variables.

PcfAuthenticationOptions options = PcfAuthenticationOptions.builder().role(…).build();
PcfAuthentication authentication = new PcfAuthentication(options, restOperations);

VaultToken token = authentication.login();

PcfAuthentication requires BouncyCastle for RSA-PSS signing.

Update KubernetesJwtSupplier and KubernetesServiceAccountTokenFile to inherit from generic Resource-based credential suppliers.

Closes gh-440.
This commit is contained in:
Mark Paluch
2019-07-31 16:26:56 +02:00
parent ba7fd9b8b4
commit f5a969bc27
20 changed files with 975 additions and 87 deletions

View File

@@ -191,6 +191,12 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<optional>true</optional>
</dependency>
<!-- Testing -->
<dependency>

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2019 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
*
* https://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.function.Supplier;
/**
* Interface to obtain an arbitrary credential that is uses in
* {@link ClientAuthentication} or {@link AuthenticationSteps} methods. Typically,
* implementations obtain their credential from a file.
*
* @author Mark Paluch
* @since 2.2
* @see ResourceCredentialSupplier
*/
@FunctionalInterface
public interface CredentialSupplier extends Supplier<String> {
/**
* Get a credential to be used with an authentication mechanism.
*
* @return the credential.
*/
@Override
String get();
/**
* Retrieve a cached {@link CredentialSupplier} that obtains the credential early and
* reuses the token for each {@link #get()} call. This is useful to prevent I/O
* operations in e.g. reactive usage.
* <p>
* Reusing a cached token can lead to authentication failures if the credential
* expires.
*
* @return a caching {@link CredentialSupplier}.
*/
default CredentialSupplier cached() {
String credential = get();
return () -> credential;
}
}

View File

@@ -61,7 +61,7 @@ public class KubernetesAuthentication
public KubernetesAuthentication(KubernetesAuthenticationOptions options,
RestOperations restOperations) {
Assert.notNull(options, "KubeAuthenticationOptions must not be null");
Assert.notNull(options, "KubernetesAuthenticationOptions must not be null");
Assert.notNull(restOperations, "RestOperations must not be null");
this.options = options;

View File

@@ -15,8 +15,6 @@
*/
package org.springframework.vault.authentication;
import java.util.function.Supplier;
/**
* Interface to obtain a Kubernetes Service Account Token for Kubernetes authentication.
* Implementations are used by {@link KubernetesAuthentication}.
@@ -27,30 +25,6 @@ import java.util.function.Supplier;
* @see KubernetesAuthentication
*/
@FunctionalInterface
public interface KubernetesJwtSupplier extends Supplier<String> {
public interface KubernetesJwtSupplier extends CredentialSupplier {
/**
* Get a JWT for Kubernetes authentication.
*
* @return the Kubernetes Service Account JWT.
*/
@Override
String get();
/**
* Retrieve a cached {@link KubernetesJwtSupplier} that obtains the JWT early and
* reuses the token for each {@link #get()} call. This is useful to prevent I/O
* operations in e.g. reactive usage.
* <p>
* Reusing a cached token can lead to authentication failures if the token expires.
*
* @return a caching {@link KubernetesJwtSupplier}.
* @since 2.2
*/
default KubernetesJwtSupplier cached() {
String jwt = get();
return () -> jwt;
}
}

View File

@@ -16,15 +16,9 @@
package org.springframework.vault.authentication;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.vault.VaultException;
/**
* Mechanism to retrieve a Kubernetes service account token.
@@ -37,15 +31,14 @@ import org.springframework.vault.VaultException;
* @since 2.0
* @see KubernetesJwtSupplier
*/
public class KubernetesServiceAccountTokenFile implements KubernetesJwtSupplier {
public class KubernetesServiceAccountTokenFile extends ResourceCredentialSupplier
implements KubernetesJwtSupplier {
/**
* Default path to the service account token file.
*/
public static final String DEFAULT_KUBERNETES_SERVICE_ACCOUNT_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token";
private final Resource resource;
/**
* Create a new {@link KubernetesServiceAccountTokenFile} pointing to the
* {@link #DEFAULT_KUBERNETES_SERVICE_ACCOUNT_TOKEN_FILE}. Construction fails with an
@@ -88,38 +81,6 @@ public class KubernetesServiceAccountTokenFile implements KubernetesJwtSupplier
* @throws IllegalArgumentException if the{@code path} does not exist.
*/
public KubernetesServiceAccountTokenFile(Resource resource) {
Assert.isTrue(resource.exists(),
() -> String.format("Resource %s does not exist", resource));
this.resource = resource;
}
@Override
public String get() {
try {
return new String(readToken(this.resource), StandardCharsets.US_ASCII);
}
catch (IOException e) {
throw new VaultException(String
.format("Kube JWT token retrieval from %s failed", this.resource), e);
}
}
/**
* Read the token from {@link Resource}.
*
* @param resource the resource to read from, must not be {@literal null}.
* @return the new byte array that has been copied to (possibly empty).
* @throws IOException in case of I/O errors.
*/
private static byte[] readToken(Resource resource) throws IOException {
Assert.notNull(resource, "Resource must not be null");
try (InputStream is = resource.getInputStream()) {
return StreamUtils.copyToByteArray(is);
}
super(resource);
}
}

View File

@@ -0,0 +1,182 @@
/*
* Copyright 2019 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
*
* https://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.nio.charset.StandardCharsets;
import java.security.spec.RSAPrivateKeySpec;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.bouncycastle.crypto.CryptoException;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.engines.RSAEngine;
import org.bouncycastle.crypto.params.RSAKeyParameters;
import org.bouncycastle.crypto.signers.PSSSigner;
import org.springframework.util.Assert;
import org.springframework.util.Base64Utils;
import org.springframework.vault.VaultException;
import org.springframework.vault.support.PemObject;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultToken;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
/**
* PCF implementation of {@link ClientAuthentication}. {@link PcfAuthentication} uses a
* PCF instance certificate and key to login into Vault.
* <p>
* Requires BouncyCastle to generate a RSA PSS signature.
*
* @author Mark Paluch
* @since 2.2
* @see PcfAuthenticationOptions
* @see RestOperations
* @see <a href="https://www.vaultproject.io/docs/auth/pcf.html">Auth Backend: PCF</a>
*/
public class PcfAuthentication
implements ClientAuthentication, AuthenticationStepsFactory {
private static final Log logger = LogFactory.getLog(PcfAuthentication.class);
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
// SHA256 hash and a salt length of 222
private static final int SALT_LENGTH = 222;
private final PcfAuthenticationOptions options;
private final RestOperations restOperations;
/**
* Create a {@link PcfAuthentication} using {@link PcfAuthenticationOptions} and
* {@link RestOperations}.
*
* @param options must not be {@literal null}.
* @param restOperations must not be {@literal null}.
*/
public PcfAuthentication(PcfAuthenticationOptions options,
RestOperations restOperations) {
Assert.notNull(options, "PcfAuthenticationOptions must not be null");
Assert.notNull(restOperations, "RestOperations must not be null");
this.options = options;
this.restOperations = restOperations;
}
/**
* Creates a {@link AuthenticationSteps} for pcf authentication given
* {@link PcfAuthenticationOptions}.
*
* @param options must not be {@literal null}.
* @return {@link AuthenticationSteps} for pcf authentication.
*/
public static AuthenticationSteps createAuthenticationSteps(
PcfAuthenticationOptions options) {
Assert.notNull(options, "PcfAuthenticationOptions must not be null");
String instanceCert = options.getInstanceCertSupplier().get();
String instanceKey = options.getInstanceKeySupplier().get();
return AuthenticationSteps
.fromSupplier(() -> getPcfLogin(options.getRole(), options.getClock(),
instanceCert, instanceKey)) //
.login("auth/{mount}/login", options.getPath());
}
@Override
public VaultToken login() throws VaultException {
Map<String, String> login = getPcfLogin(options.getRole(), options.getClock(),
options.getInstanceCertSupplier().get(),
options.getInstanceKeySupplier().get());
try {
VaultResponse response = restOperations.postForObject("auth/{mount}/login",
login, VaultResponse.class, options.getPath());
Assert.state(response != null && response.getAuth() != null,
"Auth field must not be null");
logger.debug("Login successful using PCF authentication");
return LoginTokenUtil.from(response.getAuth());
}
catch (RestClientException e) {
throw VaultLoginException.create("PCF", e);
}
}
@Override
public AuthenticationSteps getAuthenticationSteps() {
return createAuthenticationSteps(this.options);
}
private static Map<String, String> getPcfLogin(String role, Clock clock,
String instanceCert, String instanceKey) {
Assert.hasText(role, "Role must not be empty");
String signingTime = TIME_FORMAT.format(LocalDateTime.now(clock));
String message = getMessage(role, signingTime, instanceCert);
String signature = sign(message, instanceKey);
Map<String, String> login = new HashMap<>();
login.put("role", role);
login.put("cf_instance_cert", instanceCert);
login.put("signing_time", signingTime);
login.put("signature", signature);
return login;
}
private static String sign(String message, String privateKeyPem) {
try {
return doSign(message.getBytes(StandardCharsets.US_ASCII), privateKeyPem);
}
catch (CryptoException e) {
throw new VaultException("Cannot sign PCF login", e);
}
}
private static String getMessage(String role, String signingTime,
String instanceCertPem) {
return signingTime + instanceCertPem + role;
}
private static String doSign(byte[] message, String instanceKeyPem)
throws CryptoException {
RSAPrivateKeySpec privateKey = PemObject.fromKey(instanceKeyPem).getRSAKeySpec();
PSSSigner signer = new PSSSigner(new RSAEngine(), new SHA256Digest(),
SALT_LENGTH);
signer.init(true, new RSAKeyParameters(true, privateKey.getModulus(),
privateKey.getPrivateExponent()));
signer.update(message, 0, message.length);
byte[] signature = signer.generateSignature();
return Base64Utils.encodeToUrlSafeString(signature);
}
}

View File

@@ -0,0 +1,262 @@
/*
* Copyright 2019 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
*
* https://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.util.function.Supplier;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Authentication options for {@link PcfAuthentication}.
* <p>
* Authentication options provide the path, {@link Clock} and instance key/instance
* certificate {@link Supplier}s. {@link PcfAuthenticationOptions} can be constructed
* using {@link #builder()}. Instances of this class are immutable once constructed.
* <p>
* Defaults to a cached instance certificate/key by resolving {@code CF_INSTANCE_CERT} and
* {@code CF_INSTANCE_KEY} env variables.
*
* @author Mark Paluch
* @see CredentialSupplier
* @see ResourceCredentialSupplier
* @see #builder()
*/
public class PcfAuthenticationOptions {
public static final String DEFAULT_PCF_AUTHENTICATION_PATH = "pcf";
/**
* Path of the pcf authentication backend mount.
*/
private final String path;
/**
* Name of the role against which the login is being attempted.
*/
private final String role;
private final Clock clock;
/**
* Supplier instance to obtain the instance certificate.
*/
private final Supplier<String> instanceCertSupplier;
/**
* Supplier instance to obtain the instance key.
*/
private final Supplier<String> instanceKeySupplier;
private PcfAuthenticationOptions(String path, String role, Clock clock,
Supplier<String> instanceCertSupplier, Supplier<String> instanceKeySupplier) {
this.path = path;
this.role = role;
this.clock = clock;
this.instanceCertSupplier = instanceCertSupplier;
this.instanceKeySupplier = instanceKeySupplier;
}
/**
* @return a new {@link PcfAuthenticationOptionsBuilder}.
*/
public static PcfAuthenticationOptionsBuilder builder() {
return new PcfAuthenticationOptionsBuilder();
}
/**
* @return the path of the pcf authentication backend mount.
*/
public String getPath() {
return path;
}
/**
* @return name of the role against which the login is being attempted.
*/
public String getRole() {
return role;
}
/**
* @return the {@link Clock}.
*/
public Clock getClock() {
return clock;
}
/**
* @return the instance certificate {@link Supplier}.
*/
public Supplier<String> getInstanceCertSupplier() {
return instanceCertSupplier;
}
/**
* @return the instance key {@link Supplier}.
*/
public Supplier<String> getInstanceKeySupplier() {
return instanceKeySupplier;
}
/**
* Builder for {@link PcfAuthenticationOptions}.
*/
public static class PcfAuthenticationOptionsBuilder {
private String path = DEFAULT_PCF_AUTHENTICATION_PATH;
private Clock clock = Clock.systemUTC();
@Nullable
private String role;
@Nullable
private Supplier<String> instanceCertSupplier;
@Nullable
private Supplier<String> instanceKeySupplier;
PcfAuthenticationOptionsBuilder() {
}
/**
* Configure the mount path.
*
* @param path must not be empty or {@literal null}.
* @return {@code this} {@link PcfAuthenticationOptionsBuilder}.
* @see #DEFAULT_PCF_AUTHENTICATION_PATH
*/
public PcfAuthenticationOptionsBuilder path(String path) {
Assert.hasText(path, "Path must not be empty");
this.path = path;
return this;
}
/**
* Configure the role.
*
* @param role name of the role against which the login is being attempted, must
* not be {@literal null} or empty.
* @return {@code this} {@link PcfAuthenticationOptionsBuilder}.
*/
public PcfAuthenticationOptionsBuilder role(String role) {
Assert.hasText(role, "Role must not be empty");
this.role = role;
return this;
}
/**
* Configure the {@link Clock}.
*
* @param clock must not be {@literal null}.
* @return {@code this} {@link PcfAuthenticationOptionsBuilder}.
*/
public PcfAuthenticationOptionsBuilder clock(Clock clock) {
Assert.notNull(clock, "Clock must not be null");
this.clock = clock;
return this;
}
/**
* Configure the {@link Supplier} to obtain the instance certificate.
*
* @param instanceCertSupplier the supplier, must not be {@literal null}.
* @return {@code this} {@link PcfAuthenticationOptionsBuilder}.
* @see ResourceCredentialSupplier
*/
public PcfAuthenticationOptionsBuilder instanceCertificate(
Supplier<String> instanceCertSupplier) {
Assert.notNull(instanceCertSupplier,
"Instance certificate supplier must not be null");
this.instanceCertSupplier = instanceCertSupplier;
return this;
}
/**
* Configure the {@link Supplier} to obtain the instance key.
*
* @param instanceKeySupplier the supplier, must not be {@literal null}.
* @return {@code this} {@link PcfAuthenticationOptionsBuilder}.
* @see ResourceCredentialSupplier
*/
public PcfAuthenticationOptionsBuilder instanceKey(
Supplier<String> instanceKeySupplier) {
Assert.notNull(instanceKeySupplier,
"Instance certificate supplier must not be null");
this.instanceKeySupplier = instanceKeySupplier;
return this;
}
/**
* Build a new {@link PcfAuthenticationOptions} instance.
* <p>
* Falls back to the instance certificate at {@code CF_INSTANCE_CERT} if
* {@link #instanceCertificate(Supplier)} is not configured respective
* {@code CF_INSTANCE_KEY} if {@link #instanceKey(Supplier)} is not configured.
*
* @return a new {@link PcfAuthenticationOptions}.
* @throws IllegalStateException if {@link #instanceCertificate(Supplier)} or
* {@link #instanceKey(Supplier)} are not set and the corresponding
* environment variable {@code CF_INSTANCE_CERT} respective
* {@code CF_INSTANCE_KEY} is not set.
*/
public PcfAuthenticationOptions build() {
Assert.notNull(role, "Role must not be null");
Supplier<String> instanceCertSupplier = this.instanceCertSupplier;
if (instanceCertSupplier == null) {
instanceCertSupplier = new ResourceCredentialSupplier(
resolveEnvVariable("CF_INSTANCE_CERT")).cached();
}
Supplier<String> instanceKeySupplier = this.instanceKeySupplier;
if (instanceKeySupplier == null) {
instanceKeySupplier = new ResourceCredentialSupplier(
resolveEnvVariable("CF_INSTANCE_KEY")).cached();
}
return new PcfAuthenticationOptions(path, role, clock, instanceCertSupplier,
instanceKeySupplier);
}
private static String resolveEnvVariable(String name) {
String value = System.getenv(name);
if (StringUtils.isEmpty(value)) {
throw new IllegalStateException(
String.format("Environment variable %s not set", name));
}
return value;
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2019 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
*
* https://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.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.vault.VaultException;
/**
* Mechanism to retrieve a credential from a {@link Resource}.
*
* @author Mark Paluch
* @since 2.2
* @see CredentialSupplier
*/
public class ResourceCredentialSupplier implements CredentialSupplier {
private final Resource resource;
/**
* Create a new {@link ResourceCredentialSupplier} {@link ResourceCredentialSupplier}
* from a {@code path}.
*
* @param path path to the file holding the credential.
* @throws IllegalArgumentException if the{@code path} does not exist.
*/
public ResourceCredentialSupplier(String path) {
this(new FileSystemResource(path));
}
/**
* Create a new {@link ResourceCredentialSupplier} {@link ResourceCredentialSupplier}
* from a {@link File} handle.
*
* @param file path to the file holding the credential.
* @throws IllegalArgumentException if the{@code path} does not exist.
*/
public ResourceCredentialSupplier(File file) {
this(new FileSystemResource(file));
}
/**
* Create a new {@link ResourceCredentialSupplier} {@link ResourceCredentialSupplier}
* from a {@link Resource} handle.
*
* @param resource resource pointing to the resource holding the credential.
* @throws IllegalArgumentException if the {@link Resource} does not exist.
*/
public ResourceCredentialSupplier(Resource resource) {
Assert.isTrue(resource.exists(),
() -> String.format("Resource %s does not exist", resource));
this.resource = resource;
}
@Override
public String get() {
try {
return new String(readToken(this.resource), StandardCharsets.US_ASCII);
}
catch (IOException e) {
throw new VaultException(
String.format("Credential retrieval from %s failed", this.resource),
e);
}
}
/**
* Read the token from {@link Resource}.
*
* @param resource the resource to read from, must not be {@literal null}.
* @return the new byte array that has been copied to (possibly empty).
* @throws IOException in case of I/O errors.
*/
private static byte[] readToken(Resource resource) throws IOException {
Assert.notNull(resource, "Resource must not be null");
try (InputStream is = resource.getInputStream()) {
return StreamUtils.copyToByteArray(is);
}
}
}

View File

@@ -264,7 +264,13 @@ public class RestTemplateBuilder {
ClientHttpRequest request = requestFactory.createRequest(uri, httpMethod);
HttpHeaders headers = request.getHeaders();
this.defaultHeaders.forEach(headers::addIfAbsent);
this.defaultHeaders.forEach((key, value) -> {
if (!headers.containsKey(key)) {
headers.add(key, value);
}
});
this.requestCustomizers.forEach(it -> it.customize(request));
return request;

View File

@@ -106,7 +106,7 @@ public class Certificate {
byte[] bytes = Base64Utils.decodeFromString(getCertificate());
return KeystoreUtil.getCertificate(bytes);
}
catch (IOException | CertificateException e) {
catch (CertificateException e) {
throw new VaultException("Cannot create Certificate from certificate", e);
}
}
@@ -123,7 +123,7 @@ public class Certificate {
byte[] bytes = Base64Utils.decodeFromString(getIssuingCaCertificate());
return KeystoreUtil.getCertificate(bytes);
}
catch (IOException | CertificateException e) {
catch (CertificateException e) {
throw new VaultException(
"Cannot create Certificate from issuing CA certificate", e);
}

View File

@@ -22,6 +22,7 @@ import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyStore;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
@@ -40,6 +41,27 @@ import java.util.List;
*/
class KeystoreUtil {
private static final CertificateFactory CERTIFICATE_FACTORY;
private static final KeyFactory KEY_FACTORY;
static {
try {
CERTIFICATE_FACTORY = CertificateFactory.getInstance("X.509");
}
catch (CertificateException e) {
throw new IllegalStateException("No X.509 Certificate available", e);
}
try {
KEY_FACTORY = KeyFactory.getInstance("RSA");
}
catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("No RSA KeyFactory available", e);
}
}
/**
* Create a {@link KeyStore} containing the {@link KeySpec} and {@link X509Certificate
* certificates} using the given {@code keyAlias}.
@@ -54,8 +76,7 @@ class KeystoreUtil {
X509Certificate... certificates)
throws GeneralSecurityException, IOException {
KeyFactory kf = KeyFactory.getInstance("RSA");
PrivateKey privateKey = kf.generatePrivate(privateKeySpec);
PrivateKey privateKey = KEY_FACTORY.generatePrivate(privateKeySpec);
KeyStore keyStore = createKeyStore();
@@ -93,11 +114,9 @@ class KeystoreUtil {
}
static X509Certificate getCertificate(byte[] source)
throws CertificateException, IOException {
throws CertificateException {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
List<X509Certificate> certificates = getCertificates(certificateFactory, source);
List<X509Certificate> certificates = getCertificates(CERTIFICATE_FACTORY, source);
return certificates.stream().findFirst().orElseThrow(
() -> new IllegalArgumentException("No X509Certificate found"));
@@ -120,7 +139,7 @@ class KeystoreUtil {
}
private static List<X509Certificate> getCertificates(CertificateFactory cf,
byte[] source) throws CertificateException, IOException {
byte[] source) throws CertificateException {
List<X509Certificate> x509Certificates = new ArrayList<>();

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2019 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
*
* https://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.support;
import java.io.IOException;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.util.Base64Utils;
/**
* Represents a PEM object that is internally decoded to a DER object. Typically used to
* obtain a {@link RSAPrivateCrtKeySpec}.
* <p>
* Mainly for use within the framework.
*
* @author Mark Paluch
* @since 2.2
*/
public class PemObject {
private static final Pattern KEY_PATTERN = Pattern
.compile("-+BEGIN\\s+.*PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+" + // Header
"([a-z0-9+/=\\r\\n]+)" + // Base64 text
"-+END\\s+.*PRIVATE\\s+KEY[^-]*-+", // Footer
Pattern.CASE_INSENSITIVE);
private final byte[] content;
private PemObject(String content) {
String sanitized = content.replaceAll("\r", "").replaceAll("\n", "");
this.content = Base64Utils.decodeFromString(sanitized);
}
/**
* Create a{@link PemObject} from PEM {@code content} that is enclosed with
* {@code -BEGIN PRIVATE KEY-} and {@code -END PRIVATE KEY-}.
*
* @param content the PEM content.
* @return the {@link PemObject} from PEM {@code content}.
*/
public static PemObject fromKey(String content) {
Matcher m = KEY_PATTERN.matcher(content);
if (!m.find()) {
throw new IllegalArgumentException("Could not find a PKCS #8 private key");
}
return new PemObject(m.group(1));
}
/**
* Retrieve a {@link RSAPrivateCrtKeySpec}.
*
* @return the {@link RSAPrivateCrtKeySpec}.
*/
public RSAPrivateCrtKeySpec getRSAKeySpec() {
try {
return KeystoreUtil.getRSAKeySpec(this.content);
}
catch (IOException e) {
throw new IllegalArgumentException("Cannot obtain PrivateKey", e);
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2019 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
*
* https://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.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link PcfAuthenticationOptions}.
*
* @author Mark Paluch
*/
class PcfAuthenticationOptionsUnitTests {
@Test
void emptyEnvVariableShouldFailWithMeaningfulMessage() {
assertThatThrownBy(
() -> PcfAuthenticationOptions.builder().role("my-role").build())
.isInstanceOf(IllegalStateException.class) //
.hasMessageContaining(
"Environment variable CF_INSTANCE_CERT not set");
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2019 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
*
* https://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.Instant;
import java.time.ZoneId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.vault.client.VaultClients;
import org.springframework.vault.client.VaultClients.PrefixAwareUriTemplateHandler;
import org.springframework.vault.support.VaultToken;
import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* Unit tests for {@link PcfAuthentication}.
*
* @author Mark Paluch
*/
class PcfAuthenticationUnitTests {
RestTemplate restTemplate;
MockRestServiceServer mockRest;
String instanceKey = "-----BEGIN RSA PRIVATE KEY-----\n"
+ "MIIEpAIBAAKCAQEAzhDAw7m5EuCcQkT7cesJF9J/0FWeSICLg4F/3R8EpkjPfZSW\n"
+ "BeIjSs6v+wUjPCdBomZpYnrphYvSdiDrHWIDKrFNcWMFms0t6A0jEyGG/k9xDf6u\n"
+ "VjDw4Gi4LJ0o0sNK0y9ULgdrViwRkSKNdhZx+34l/aeWyrg9WG6KAdjbPQE2J4Vi\n"
+ "3x7yJVEh/Ya0uR/UtN/8hB8Makuaz6SRcLvooUO0FXYpy20olA/nlmowCl1PxpVv\n"
+ "smZZG7qDJGL/P3C3M9YRX4VPXLTiyvEFwIgWNz6QIhX/Enm0q+xyw+gd5hRQzF4Q\n"
+ "Fs03VrzHsQX9H3GMd/HoqsM1vD+PPfpKI7re7wIDAQABAoIBAQCk83oq8wd4Wf4b\n"
+ "ejbBWQB9Zk5UCcVbijKjwU0GR2ckaNJXV1LEQOI5ZrwuN02eQFpk0o/3eiZmdaey\n"
+ "UeWDLssUKLuyUS7SXP4rbCCwlr0F47e/GSia7DBVot4TMHbWR+gkpxU+h0ffwgUJ\n"
+ "5dvRNGRnifKFWtr1SYgpusqUcfAFovvcbLZ4X+VkV7uazHAbtor5ol2UuRN04Hnu\n"
+ "i5gxKBvr/j4ULDnZDJZjJKPVyaS9x4ewVJx241mFbE3aOWxRl6QwouqXTqhIs/Lv\n"
+ "85MR67nuVlEWMToR/sx0/5Xg/BJDHLUCRM5MNx+wD+Fyj6DlJn2A63Ly4HQZZuYG\n"
+ "Z48BGku5AoGBAOFmlJQITfp5xtu2oy10bwtDElHV3HBAtcJ7OHgu9ZniNNhgZ7ud\n"
+ "pw9VC/bL8qZ8iToooKA7AgecSmaDeX0qWPgE9uaheaT76x/6Kn3Ue/ZfV2pSrStu\n"
+ "YlFc5UxG3Z3klAx/Y+DA7rLb2Y0U6olLx5A7Fg+V/feD8mYxnyZy2r/bAoGBAOoK\n"
+ "NVIyzcv13S7OqiCo4nhF2CcDWekGTzmsFygwvNwiM1CipzFifQQw6Z+0rl1FwxN0\n"
+ "uGs3x41iTmkVOyASO78Li9aPQHZWhRWQI82689kYcnChQnDZGLfIv/WVxGy3DgKd\n"
+ "FdJIOSLagTwhUOnZ4kaR6Mj2Jc4RXYjwpoQUJON9AoGBALOqP75rjDSegvs5boJZ\n"
+ "7/WLJfwjOw4jFn6KF638yHo7zCG5XpY3CSX4hYvYb3dzhzLblYWC45BLbSafn+Q8\n"
+ "MCSqWF/n0H3I7FdV4i7gg1sUDirK8gvPdgEiygdt6VLlE3mOxX8ualYZViTVyklc\n"
+ "JRt7bY9I4OI9w6bf4NsV6/XHAoGAISd5Dj/sL2yQ/MSCDUZfbrJWQJCU+BHQv1bF\n"
+ "oQfmeTjPFCk2jiRpmWJkdh9eZBAx5luulGG+fyTh/rjnO0/Z7uJv2OFKPHldOQTG\n"
+ "TaqiSKrR62qswte+TKq/psakoNH9xhkCsltQ3MMfc6k0kSwwhda9p1pXWK3VFkUh\n"
+ "EazY3PECgYAoo8jvvQTKlXBmnVU1R//16fCklJXqYcEeOAO0CgyPCxHuAULK++M/\n"
+ "HRHd+6FippoH4ppSACEqQO5TwBTYxgOCwOcYZaRDvYZqEbgPNlf3oZ73kRyoIeAK\n"
+ "zvaXPNUuUEoW4E9Y2M+9SzF+975TTjqwjBgoCIF3xd+xQfgV9vkB6w==\n"
+ "-----END RSA PRIVATE KEY-----";
Clock clock = Clock.fixed(Instant.parse("2007-12-03T10:15:30.00Z"), ZoneId.of("UTC"));
@BeforeEach
void before() {
RestTemplate restTemplate = VaultClients.createRestTemplate();
restTemplate.setUriTemplateHandler(new PrefixAwareUriTemplateHandler());
this.mockRest = MockRestServiceServer.createServer(restTemplate);
this.restTemplate = restTemplate;
}
@Test
void loginShouldObtainToken() {
PcfAuthenticationOptions options = PcfAuthenticationOptions.builder()
.instanceCertificate(() -> "foo") //
.instanceKey(() -> instanceKey) //
.role("dev-role") //
.clock(clock) //
.build();
PcfAuthentication authentication = new PcfAuthentication(options, restTemplate);
expectLoginRequest();
VaultToken login = authentication.login();
assertThat(login).isInstanceOf(LoginToken.class);
assertThat(login.getToken()).isEqualTo("my-token");
}
@Test
void loginWithStepsShouldObtainToken() {
PcfAuthenticationOptions options = PcfAuthenticationOptions.builder()
.instanceCertificate(() -> "foo") //
.instanceKey(() -> instanceKey) //
.role("dev-role") //
.clock(clock) //
.build();
expectLoginRequest();
AuthenticationStepsExecutor authentication = new AuthenticationStepsExecutor(
PcfAuthentication.createAuthenticationSteps(options), restTemplate);
VaultToken login = authentication.login();
assertThat(login).isInstanceOf(LoginToken.class);
assertThat(login.getToken()).isEqualTo("my-token");
}
private void expectLoginRequest() {
mockRest.expect(requestTo("/auth/pcf/login")).andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.role").value("dev-role"))
.andExpect(jsonPath("$.signature").exists())
.andExpect(jsonPath("$.cf_instance_cert").value("foo"))
.andExpect(jsonPath("$.signing_time").value("2007-12-03T10:15:30Z"))
.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
.body("{" + "\"auth\":{\"client_token\":\"my-token\"}" + "}"));
}
}

View File

@@ -22,6 +22,7 @@ import org.springframework.vault.client.VaultEndpoint;
import org.springframework.vault.config.AbstractReactiveVaultConfiguration;
import org.springframework.vault.support.SslConfiguration;
import org.springframework.vault.util.Settings;
import org.springframework.vault.util.TestRestTemplateFactory;
/**
* Test configuration for Vault integration tests.
@@ -34,7 +35,7 @@ public class VaultIntegrationTestConfiguration
@Override
public VaultEndpoint vaultEndpoint() {
return new VaultEndpoint();
return TestRestTemplateFactory.TEST_VAULT_ENDPOINT;
}
@Override

View File

@@ -18,7 +18,6 @@ package org.springframework.vault.core;
import java.util.Collections;
import java.util.List;
import org.apache.commons.codec.binary.Base64;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -27,6 +26,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.Base64Utils;
import org.springframework.vault.support.VaultMount;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultTransitKeyConfiguration;
@@ -103,7 +103,7 @@ class VaultTemplateTransitIntegrationTests extends IntegrationTestSupport {
VaultResponse response = vaultOperations.write("transit/encrypt/mykey",
Collections.singletonMap("plaintext",
Base64.encodeBase64String("that message is secret".getBytes())));
Base64Utils.encodeToString("that message is secret".getBytes())));
assertThat((String) response.getRequiredData().get("ciphertext")).isNotEmpty();
}
@@ -113,13 +113,13 @@ class VaultTemplateTransitIntegrationTests extends IntegrationTestSupport {
VaultResponse response = vaultOperations.write("transit/encrypt/mykey",
Collections.singletonMap("plaintext",
Base64.encodeBase64String("that message is secret".getBytes())));
Base64Utils.encodeToString("that message is secret".getBytes())));
VaultResponse decrypted = vaultOperations.write("transit/decrypt/mykey",
Collections.singletonMap("ciphertext",
response.getRequiredData().get("ciphertext")));
assertThat((String) decrypted.getRequiredData().get("plaintext")).isEqualTo(
Base64.encodeBase64String("that message is secret".getBytes()));
Base64Utils.encodeToString("that message is secret".getBytes()));
}
}

View File

@@ -29,7 +29,7 @@ import org.springframework.web.reactive.function.client.WebClient;
*/
public class TestWebClientFactory {
private static final VaultEndpoint TEST_VAULT_ENDPOINT = new VaultEndpoint();
private static final VaultEndpoint TEST_VAULT_ENDPOINT = TestRestTemplateFactory.TEST_VAULT_ENDPOINT;
/**
* Create a new {@link WebClient} using the {@link SslConfiguration}. See

View File

@@ -66,6 +66,7 @@
<aws-java-sdk.version>1.11.605</aws-java-sdk.version>
<google-api-services-iam.version>v1-rev20190704-1.30.1</google-api-services-iam.version>
<google-auth-library-oauth2-http.version>0.16.2</google-auth-library-oauth2-http.version>
<bcpkix-jdk15on.version>1.62</bcpkix-jdk15on.version>
</properties>
<dependencyManagement>
@@ -150,6 +151,15 @@
<optional>true</optional>
</dependency>
<!-- BouncyCastle -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>${bcpkix-jdk15on.version}</version>
<optional>true</optional>
</dependency>
</dependencies>
</dependencyManagement>
@@ -209,7 +219,8 @@
<repository>
<id>sonatype-nexus-staging</id>
<name>Nexus Release Repository</name>
<url>https://oss.sonatype.org/service/local/staging/deploy/maven2/</url>
<url>https://oss.sonatype.org/service/local/staging/deploy/maven2/
</url>
</repository>
</distributionManagement>

View File

@@ -8,6 +8,7 @@
* SpEL support in `@Secret`.
* Add support for Jetty as reactive HttpClient.
* `LifecycleAwareSessionManager` and `ReactiveLifecycleAwareSessionManager` emit now ``AuthenticationEvent``s.
* <<vault.authentication.pcf>>.
* Deprecation of `AppIdAuthentication`. Use `AppRoleAuthentication` instead as recommended by HashiCorp Vault.
[[new-features.2-1-0]]

View File

@@ -531,6 +531,52 @@ 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.pcf]]
== PCF authentication
The https://www.vaultproject.io/docs/auth/pcf.html[pcf]
auth backend allows Vault login for PCF instances.
It leverages https://content.pivotal.io/blog/new-in-pcf-2-1-app-container-identity-assurance-via-automatic-cert-rotation[PCF's App and Container Identity Assurance].
PCF authentication uses the instance key and certificate to create a signature that is validated by Vault.
If the signature matches, and potentially bound organization/space/application Id's match, Vault issues an appropriately-scoped token.
Instance credentials are available from files at `CF_INSTANCE_CERT` and
`CF_INSTANCE_KEY` variables.
====
[source,java]
----
@Configuration
class AppConfig extends AbstractVaultConfiguration {
// …
@Override
public ClientAuthentication clientAuthentication() {
PcfAuthenticationOptions options = PcfAuthenticationOptions.builder()
.role(…).build();
PcfAuthentication authentication = new PcfAuthentication(options,
restOperations());
}
// …
}
----
====
`PcfAuthenticationOptions` requires the https://www.bouncycastle.org/latest_releases.html[BouncyCastle]
library for creating RSA-PSS signatures.
You can configure the authentication via `PcfAuthenticationOptions`.
See also:
* https://www.vaultproject.io/docs/auth/pcf.html[Vault Documentation:
Using the PCF auth backend]
[[vault.authentication.clientcert]]
== TLS certificate authentication