Support Couchbase authentication using client certificates

Closes gh-41520
This commit is contained in:
Scott Frederick 2024-08-12 16:05:37 -05:00
parent d56dd741d0
commit add3d87ea1
5 changed files with 250 additions and 12 deletions

View File

@ -16,8 +16,16 @@
package org.springframework.boot.autoconfigure.couchbase;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import javax.net.ssl.TrustManagerFactory;
import com.couchbase.client.core.env.Authenticator;
import com.couchbase.client.core.env.CertificateAuthenticator;
import com.couchbase.client.core.env.PasswordAuthenticator;
import com.couchbase.client.java.Cluster;
import com.couchbase.client.java.ClusterOptions;
import com.couchbase.client.java.codec.JacksonJsonSerializer;
@ -36,16 +44,22 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseAutoConfiguration.CouchbaseCondition;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseProperties.Authentication.Jks;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseProperties.Authentication.Pem;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseProperties.Ssl;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseProperties.Timeouts;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.io.ApplicationResourceLoader;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.ssl.pem.PemSslStore;
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@ -58,6 +72,7 @@ import org.springframework.util.StringUtils;
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
* @author Scott Frederick
* @since 1.4.0
*/
@AutoConfiguration(after = JacksonAutoConfiguration.class)
@ -80,25 +95,51 @@ public class CouchbaseAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public ClusterEnvironment couchbaseClusterEnvironment(CouchbaseConnectionDetails connectionDetails,
public ClusterEnvironment couchbaseClusterEnvironment(
ObjectProvider<ClusterEnvironmentBuilderCustomizer> customizers, ObjectProvider<SslBundles> sslBundles) {
Builder builder = initializeEnvironmentBuilder(connectionDetails, sslBundles.getIfAvailable());
Builder builder = initializeEnvironmentBuilder(sslBundles.getIfAvailable());
customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
return builder.build();
}
@Bean
@ConditionalOnMissingBean
public Authenticator couchbaseAuthenticator(CouchbaseConnectionDetails connectionDetails) throws IOException {
if (connectionDetails.getUsername() != null && connectionDetails.getPassword() != null) {
return PasswordAuthenticator.create(connectionDetails.getUsername(), connectionDetails.getPassword());
}
Pem pem = this.properties.getAuthentication().getPem();
if (pem.getCertificates() != null) {
PemSslStoreDetails details = new PemSslStoreDetails(null, pem.getCertificates(), pem.getPrivateKey());
PemSslStore store = PemSslStore.load(details);
return CertificateAuthenticator.fromKey(store.privateKey(), pem.getPrivateKeyPassword(),
store.certificates());
}
Jks jks = this.properties.getAuthentication().getJks();
if (jks.getLocation() != null) {
Resource resource = new ApplicationResourceLoader().getResource(jks.getLocation());
String keystorePassword = jks.getPassword();
try (InputStream inputStream = resource.getInputStream()) {
KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType());
store.load(inputStream, (keystorePassword != null) ? keystorePassword.toCharArray() : null);
return CertificateAuthenticator.fromKeyStore(store, keystorePassword);
}
catch (GeneralSecurityException ex) {
throw new IllegalStateException("Error reading Couchbase certificate store", ex);
}
}
throw new IllegalStateException("Couchbase authentication requires username and password, or certificates");
}
@Bean(destroyMethod = "disconnect")
@ConditionalOnMissingBean
public Cluster couchbaseCluster(ClusterEnvironment couchbaseClusterEnvironment,
public Cluster couchbaseCluster(ClusterEnvironment couchbaseClusterEnvironment, Authenticator authenticator,
CouchbaseConnectionDetails connectionDetails) {
ClusterOptions options = ClusterOptions
.clusterOptions(connectionDetails.getUsername(), connectionDetails.getPassword())
.environment(couchbaseClusterEnvironment);
ClusterOptions options = ClusterOptions.clusterOptions(authenticator).environment(couchbaseClusterEnvironment);
return Cluster.connect(connectionDetails.getConnectionString(), options);
}
private ClusterEnvironment.Builder initializeEnvironmentBuilder(CouchbaseConnectionDetails connectionDetails,
SslBundles sslBundles) {
private ClusterEnvironment.Builder initializeEnvironmentBuilder(SslBundles sslBundles) {
ClusterEnvironment.Builder builder = ClusterEnvironment.builder();
Timeouts timeouts = this.properties.getEnv().getTimeouts();
builder.timeoutConfig((config) -> config.kvTimeout(timeouts.getKeyValue())

View File

@ -29,6 +29,7 @@ import org.springframework.util.StringUtils;
* @author Yulin Qin
* @author Brian Clozel
* @author Michael Nitschinger
* @author Scott Frederick
* @since 1.4.0
*/
@ConfigurationProperties(prefix = "spring.couchbase")
@ -49,6 +50,8 @@ public class CouchbaseProperties {
*/
private String password;
private final Authentication authentication = new Authentication();
private final Env env = new Env();
public String getConnectionString() {
@ -75,10 +78,116 @@ public class CouchbaseProperties {
this.password = password;
}
public Authentication getAuthentication() {
return this.authentication;
}
public Env getEnv() {
return this.env;
}
public static class Authentication {
private final Pem pem = new Pem();
private final Jks jks = new Jks();
public Pem getPem() {
return this.pem;
}
public Jks getJks() {
return this.jks;
}
public static class Pem {
/**
* PEM-formatted certificates for certificate-based cluster authentication.
*/
private String certificates;
/**
* PEM-formatted private key for certificate-based cluster authentication.
*/
private String privateKey;
/**
* Private key password for certificate-based cluster authentication.
*/
private String privateKeyPassword;
public String getCertificates() {
return this.certificates;
}
public void setCertificates(String certificates) {
this.certificates = certificates;
}
public String getPrivateKey() {
return this.privateKey;
}
public void setPrivateKey(String privateKey) {
this.privateKey = privateKey;
}
public String getPrivateKeyPassword() {
return this.privateKeyPassword;
}
public void setPrivateKeyPassword(String privateKeyPassword) {
this.privateKeyPassword = privateKeyPassword;
}
}
public static class Jks {
/**
* Java KeyStore location for certificate-based cluster authentication.
*/
private String location;
/**
* Java KeyStore password for certificate-based cluster authentication.
*/
private String password;
/**
* Private key password for certificate-based cluster authentication.
*/
private String privateKeyPassword;
public String getLocation() {
return this.location;
}
public void setLocation(String location) {
this.location = location;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
this.password = password;
}
public String getPrivateKeyPassword() {
return this.privateKeyPassword;
}
public void setPrivateKeyPassword(String privateKeyPassword) {
this.privateKeyPassword = privateKeyPassword;
}
}
}
public static class Env {
private final Io io = new Io();

View File

@ -21,7 +21,10 @@ import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import com.couchbase.client.core.env.Authenticator;
import com.couchbase.client.core.env.CertificateAuthenticator;
import com.couchbase.client.core.env.IoConfig;
import com.couchbase.client.core.env.PasswordAuthenticator;
import com.couchbase.client.core.env.SecurityConfig;
import com.couchbase.client.core.env.TimeoutConfig;
import com.couchbase.client.java.Cluster;
@ -54,6 +57,7 @@ import static org.mockito.Mockito.mock;
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
* @author Scott Frederick
*/
class CouchbaseAutoConfigurationTests {
@ -63,6 +67,7 @@ class CouchbaseAutoConfigurationTests {
@Test
void connectionStringIsRequired() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ClusterEnvironment.class)
.doesNotHaveBean(Authenticator.class)
.doesNotHaveBean(Cluster.class));
}
@ -79,6 +84,7 @@ class CouchbaseAutoConfigurationTests {
.run((context) -> {
assertThat(context).hasSingleBean(ClusterEnvironment.class)
.hasSingleBean(Cluster.class)
.hasSingleBean(PasswordAuthenticator.class)
.hasSingleBean(CouchbaseConnectionDetails.class)
.doesNotHaveBean(PropertiesCouchbaseConnectionDetails.class);
Cluster cluster = context.getBean(Cluster.class);
@ -94,19 +100,24 @@ class CouchbaseAutoConfigurationTests {
this.contextRunner.withUserConfiguration(CouchbaseTestConfiguration.class)
.withPropertyValues("spring.couchbase.connection-string=localhost")
.run((context) -> {
assertThat(context).hasSingleBean(ClusterEnvironment.class).hasSingleBean(Cluster.class);
assertThat(context).hasSingleBean(ClusterEnvironment.class)
.hasSingleBean(Authenticator.class)
.hasSingleBean(Cluster.class);
assertThat(context).doesNotHaveBean("couchbaseAuthenticator");
assertThat(context.getBean(Cluster.class))
.isSameAs(context.getBean(CouchbaseTestConfiguration.class).couchbaseCluster());
});
}
@Test
void connectionDetailsShouldOverrideProperties() {
void connectionDetailsOverridesProperties() {
this.contextRunner.withBean(CouchbaseConnectionDetails.class, this::couchbaseConnectionDetails)
.withPropertyValues("spring.couchbase.connection-string=localhost", "spring.couchbase.username=a-user",
"spring.couchbase.password=a-password")
.run((context) -> {
assertThat(context).hasSingleBean(ClusterEnvironment.class).hasSingleBean(Cluster.class);
assertThat(context).hasSingleBean(ClusterEnvironment.class)
.hasSingleBean(PasswordAuthenticator.class)
.hasSingleBean(Cluster.class);
Cluster cluster = context.getBean(Cluster.class);
assertThat(cluster.core()).extracting("connectionString.hosts")
.asInstanceOf(InstanceOfAssertFactories.LIST)
@ -243,6 +254,41 @@ class CouchbaseAutoConfigurationTests {
});
}
@Test
void passwordAuthenticationWithUsernameAndPassword() {
this.contextRunner
.withPropertyValues("spring.couchbase.connection-string=localhost", "spring.couchbase.username=user",
"spring.couchbase.password=secret")
.run((context) -> assertThat(context).hasSingleBean(PasswordAuthenticator.class));
}
@Test
void certificateAuthenticationWithPemPrivateKeyAndCertificate() {
this.contextRunner.withPropertyValues("spring.couchbase.connection-string=localhost",
"spring.couchbase.env.ssl.enabled=true",
"spring.couchbase.authentication.pem.private-key=classpath:org/springframework/boot/autoconfigure/ssl/key2.pem",
"spring.couchbase.authentication.pem.certificates=classpath:org/springframework/boot/autoconfigure/ssl/key2.crt")
.run((context) -> assertThat(context).hasSingleBean(CertificateAuthenticator.class));
}
@Test
void certificateAuthenticationWithJavaKeyStore() {
this.contextRunner.withPropertyValues("spring.couchbase.connection-string=localhost",
"spring.couchbase.env.ssl.enabled=true",
"spring.couchbase.authentication.jks.location=classpath:org/springframework/boot/autoconfigure/ssl/keystore.jks",
"spring.couchbase.authentication.jks.password=secret")
.run((context) -> assertThat(context).hasSingleBean(CertificateAuthenticator.class));
}
@Test
void failsWithMissingAuthentication() {
this.contextRunner.withPropertyValues("spring.couchbase.connection-string=localhost").run((context) -> {
assertThat(context).hasFailed();
assertThat(context).getFailure()
.hasMessageContaining("Couchbase authentication requires username and password, or certificates");
});
}
private CouchbaseConnectionDetails couchbaseConnectionDetails() {
return new CouchbaseConnectionDetails() {

View File

@ -16,6 +16,7 @@
package org.springframework.boot.autoconfigure.couchbase;
import com.couchbase.client.core.env.Authenticator;
import com.couchbase.client.java.Cluster;
import org.springframework.context.annotation.Bean;
@ -27,15 +28,23 @@ import static org.mockito.Mockito.mock;
* Test configuration for couchbase that mocks access.
*
* @author Stephane Nicoll
* @author Scott Frederick
*/
@Configuration(proxyBeanMethods = false)
class CouchbaseTestConfiguration {
private final Cluster cluster = mock(Cluster.class);
private final Authenticator authenticator = mock(Authenticator.class);
@Bean
Cluster couchbaseCluster() {
return this.cluster;
}
@Bean
Authenticator couchbaseAuth() {
return this.authenticator;
}
}

View File

@ -543,7 +543,7 @@ There are `spring-boot-starter-data-couchbase` and `spring-boot-starter-data-cou
You can get a `Cluster` by adding the Couchbase SDK and some configuration.
The `spring.couchbase.*` properties can be used to customize the connection.
Generally, you provide the https://github.com/couchbaselabs/sdk-rfcs/blob/master/rfc/0011-connection-string.md[connection string], username, and password, as shown in the following example:
Generally, you provide the https://docs.couchbase.com/dotnet-sdk/current/howtos/managing-connections.html[connection string] and credentials for authentication. Basic authentication with username and password can be configured as shown in the following example:
[configprops,yaml]
----
@ -554,6 +554,39 @@ spring:
password: "secret"
----
https://docs.couchbase.com/server/current/manage/manage-security/configure-client-certificates.html[Client certificates] can be used for authentication instead of username and password.
The location and password for a Java KeyStore containing client certificates can be configured as shown in the following example:
[configprops,yaml]
----
spring:
couchbase:
connection-string: "couchbase://192.168.1.123"
env:
ssl:
enabled: true
authentication:
jks:
location: "classpath:client.p12"
password: "secret"
----
PEM-encoded certificates and a private key can be configured as shown in the following example:
[configprops,yaml]
----
spring:
couchbase:
connection-string: "couchbase://192.168.1.123"
env:
ssl:
enabled: true
authentication:
pem:
certificates: "classpath:client.crt"
private-key: "classpath:client.key"
----
It is also possible to customize some of the `ClusterEnvironment` settings.
For instance, the following configuration changes the timeout to open a new `Bucket` and enables SSL support with a reference to a configured xref:features/ssl.adoc[SSL bundle]: