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; 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 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.Cluster;
import com.couchbase.client.java.ClusterOptions; import com.couchbase.client.java.ClusterOptions;
import com.couchbase.client.java.codec.JacksonJsonSerializer; 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.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseAutoConfiguration.CouchbaseCondition; 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.Ssl;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseProperties.Timeouts; import org.springframework.boot.autoconfigure.couchbase.CouchbaseProperties.Timeouts;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.io.ApplicationResourceLoader;
import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles; 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.Bean;
import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -58,6 +72,7 @@ import org.springframework.util.StringUtils;
* @author Moritz Halbritter * @author Moritz Halbritter
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
* @since 1.4.0 * @since 1.4.0
*/ */
@AutoConfiguration(after = JacksonAutoConfiguration.class) @AutoConfiguration(after = JacksonAutoConfiguration.class)
@ -80,25 +95,51 @@ public class CouchbaseAutoConfiguration {
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
public ClusterEnvironment couchbaseClusterEnvironment(CouchbaseConnectionDetails connectionDetails, public ClusterEnvironment couchbaseClusterEnvironment(
ObjectProvider<ClusterEnvironmentBuilderCustomizer> customizers, ObjectProvider<SslBundles> sslBundles) { ObjectProvider<ClusterEnvironmentBuilderCustomizer> customizers, ObjectProvider<SslBundles> sslBundles) {
Builder builder = initializeEnvironmentBuilder(connectionDetails, sslBundles.getIfAvailable()); Builder builder = initializeEnvironmentBuilder(sslBundles.getIfAvailable());
customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
return builder.build(); 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") @Bean(destroyMethod = "disconnect")
@ConditionalOnMissingBean @ConditionalOnMissingBean
public Cluster couchbaseCluster(ClusterEnvironment couchbaseClusterEnvironment, public Cluster couchbaseCluster(ClusterEnvironment couchbaseClusterEnvironment, Authenticator authenticator,
CouchbaseConnectionDetails connectionDetails) { CouchbaseConnectionDetails connectionDetails) {
ClusterOptions options = ClusterOptions ClusterOptions options = ClusterOptions.clusterOptions(authenticator).environment(couchbaseClusterEnvironment);
.clusterOptions(connectionDetails.getUsername(), connectionDetails.getPassword())
.environment(couchbaseClusterEnvironment);
return Cluster.connect(connectionDetails.getConnectionString(), options); return Cluster.connect(connectionDetails.getConnectionString(), options);
} }
private ClusterEnvironment.Builder initializeEnvironmentBuilder(CouchbaseConnectionDetails connectionDetails, private ClusterEnvironment.Builder initializeEnvironmentBuilder(SslBundles sslBundles) {
SslBundles sslBundles) {
ClusterEnvironment.Builder builder = ClusterEnvironment.builder(); ClusterEnvironment.Builder builder = ClusterEnvironment.builder();
Timeouts timeouts = this.properties.getEnv().getTimeouts(); Timeouts timeouts = this.properties.getEnv().getTimeouts();
builder.timeoutConfig((config) -> config.kvTimeout(timeouts.getKeyValue()) builder.timeoutConfig((config) -> config.kvTimeout(timeouts.getKeyValue())

View File

@ -29,6 +29,7 @@ import org.springframework.util.StringUtils;
* @author Yulin Qin * @author Yulin Qin
* @author Brian Clozel * @author Brian Clozel
* @author Michael Nitschinger * @author Michael Nitschinger
* @author Scott Frederick
* @since 1.4.0 * @since 1.4.0
*/ */
@ConfigurationProperties(prefix = "spring.couchbase") @ConfigurationProperties(prefix = "spring.couchbase")
@ -49,6 +50,8 @@ public class CouchbaseProperties {
*/ */
private String password; private String password;
private final Authentication authentication = new Authentication();
private final Env env = new Env(); private final Env env = new Env();
public String getConnectionString() { public String getConnectionString() {
@ -75,10 +78,116 @@ public class CouchbaseProperties {
this.password = password; this.password = password;
} }
public Authentication getAuthentication() {
return this.authentication;
}
public Env getEnv() { public Env getEnv() {
return this.env; 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 { public static class Env {
private final Io io = new Io(); private final Io io = new Io();

View File

@ -21,7 +21,10 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; 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.IoConfig;
import com.couchbase.client.core.env.PasswordAuthenticator;
import com.couchbase.client.core.env.SecurityConfig; import com.couchbase.client.core.env.SecurityConfig;
import com.couchbase.client.core.env.TimeoutConfig; import com.couchbase.client.core.env.TimeoutConfig;
import com.couchbase.client.java.Cluster; import com.couchbase.client.java.Cluster;
@ -54,6 +57,7 @@ import static org.mockito.Mockito.mock;
* @author Moritz Halbritter * @author Moritz Halbritter
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
*/ */
class CouchbaseAutoConfigurationTests { class CouchbaseAutoConfigurationTests {
@ -63,6 +67,7 @@ class CouchbaseAutoConfigurationTests {
@Test @Test
void connectionStringIsRequired() { void connectionStringIsRequired() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ClusterEnvironment.class) this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ClusterEnvironment.class)
.doesNotHaveBean(Authenticator.class)
.doesNotHaveBean(Cluster.class)); .doesNotHaveBean(Cluster.class));
} }
@ -79,6 +84,7 @@ class CouchbaseAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(ClusterEnvironment.class) assertThat(context).hasSingleBean(ClusterEnvironment.class)
.hasSingleBean(Cluster.class) .hasSingleBean(Cluster.class)
.hasSingleBean(PasswordAuthenticator.class)
.hasSingleBean(CouchbaseConnectionDetails.class) .hasSingleBean(CouchbaseConnectionDetails.class)
.doesNotHaveBean(PropertiesCouchbaseConnectionDetails.class); .doesNotHaveBean(PropertiesCouchbaseConnectionDetails.class);
Cluster cluster = context.getBean(Cluster.class); Cluster cluster = context.getBean(Cluster.class);
@ -94,19 +100,24 @@ class CouchbaseAutoConfigurationTests {
this.contextRunner.withUserConfiguration(CouchbaseTestConfiguration.class) this.contextRunner.withUserConfiguration(CouchbaseTestConfiguration.class)
.withPropertyValues("spring.couchbase.connection-string=localhost") .withPropertyValues("spring.couchbase.connection-string=localhost")
.run((context) -> { .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)) assertThat(context.getBean(Cluster.class))
.isSameAs(context.getBean(CouchbaseTestConfiguration.class).couchbaseCluster()); .isSameAs(context.getBean(CouchbaseTestConfiguration.class).couchbaseCluster());
}); });
} }
@Test @Test
void connectionDetailsShouldOverrideProperties() { void connectionDetailsOverridesProperties() {
this.contextRunner.withBean(CouchbaseConnectionDetails.class, this::couchbaseConnectionDetails) this.contextRunner.withBean(CouchbaseConnectionDetails.class, this::couchbaseConnectionDetails)
.withPropertyValues("spring.couchbase.connection-string=localhost", "spring.couchbase.username=a-user", .withPropertyValues("spring.couchbase.connection-string=localhost", "spring.couchbase.username=a-user",
"spring.couchbase.password=a-password") "spring.couchbase.password=a-password")
.run((context) -> { .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); Cluster cluster = context.getBean(Cluster.class);
assertThat(cluster.core()).extracting("connectionString.hosts") assertThat(cluster.core()).extracting("connectionString.hosts")
.asInstanceOf(InstanceOfAssertFactories.LIST) .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() { private CouchbaseConnectionDetails couchbaseConnectionDetails() {
return new CouchbaseConnectionDetails() { return new CouchbaseConnectionDetails() {

View File

@ -16,6 +16,7 @@
package org.springframework.boot.autoconfigure.couchbase; package org.springframework.boot.autoconfigure.couchbase;
import com.couchbase.client.core.env.Authenticator;
import com.couchbase.client.java.Cluster; import com.couchbase.client.java.Cluster;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@ -27,15 +28,23 @@ import static org.mockito.Mockito.mock;
* Test configuration for couchbase that mocks access. * Test configuration for couchbase that mocks access.
* *
* @author Stephane Nicoll * @author Stephane Nicoll
* @author Scott Frederick
*/ */
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
class CouchbaseTestConfiguration { class CouchbaseTestConfiguration {
private final Cluster cluster = mock(Cluster.class); private final Cluster cluster = mock(Cluster.class);
private final Authenticator authenticator = mock(Authenticator.class);
@Bean @Bean
Cluster couchbaseCluster() { Cluster couchbaseCluster() {
return this.cluster; 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. You can get a `Cluster` by adding the Couchbase SDK and some configuration.
The `spring.couchbase.*` properties can be used to customize the connection. 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] [configprops,yaml]
---- ----
@ -554,6 +554,39 @@ spring:
password: "secret" 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. 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]: 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]: