diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java index 5b72723e1cb..20a7d7899ab 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java @@ -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 customizers, ObjectProvider 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()) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java index fbe2d5878ea..2f4d2e8a0d0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java @@ -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(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java index 6b38551d5f8..73b5f3ded97 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java @@ -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() { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseTestConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseTestConfiguration.java index b2b1cf8c852..cae2367c40b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseTestConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseTestConfiguration.java @@ -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; + } + } diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc index 108664b4915..3709840f882 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc @@ -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]: