diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc index fadce3ad13b..8bb18267aef 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc @@ -736,3 +736,20 @@ If you want to load the initialization script from a different resource, you can By default, a standard schema is used to validate `LDIF` files. You can turn off validation altogether by setting the configprop:spring.ldap.embedded.validation.enabled[] property. If you have custom attributes, you can use configprop:spring.ldap.embedded.validation.schema[] to define your custom attribute types or object classes. + + + +[[data.nosql.ldap.embedded.ssl]] +==== SSL + +The in-memory LDAP server supports SSL (LDAPS). +To enable SSL, configure the xref:features/ssl.adoc[SSL bundle] to use by setting the configprop:spring.ldap.embedded.ssl.bundle[] property, as shown in the following example: + +[configprops,yaml] +---- +spring: + ldap: + embedded: + ssl: + bundle: "example" +---- diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfiguration.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfiguration.java index 823ab1dd3e5..a2f51bc1419 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfiguration.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfiguration.java @@ -22,6 +22,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSocketFactory; + import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; import com.unboundid.ldap.listener.InMemoryListenerConfig; @@ -33,6 +37,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionMessage; @@ -47,6 +52,9 @@ import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.ldap.autoconfigure.LdapAutoConfiguration; import org.springframework.boot.ldap.autoconfigure.LdapProperties; import org.springframework.boot.ldap.autoconfigure.embedded.EmbeddedLdapAutoConfiguration.EmbeddedLdapAutoConfigurationRuntimeHints; +import org.springframework.boot.ldap.autoconfigure.embedded.EmbeddedLdapProperties.Ssl; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; @@ -63,6 +71,7 @@ import org.springframework.core.io.Resource; import org.springframework.core.type.AnnotatedTypeMetadata; import org.springframework.ldap.core.ContextSource; import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** @@ -91,7 +100,8 @@ public final class EmbeddedLdapAutoConfiguration implements DisposableBean { } @Bean - InMemoryDirectoryServer directoryServer(ApplicationContext applicationContext) throws LDAPException { + InMemoryDirectoryServer directoryServer(ApplicationContext applicationContext, + ObjectProvider sslBundles) throws LDAPException { String[] baseDn = StringUtils.toStringArray(this.embeddedProperties.getBaseDn()); InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(baseDn); String username = this.embeddedProperties.getCredential().getUsername(); @@ -99,10 +109,8 @@ public final class EmbeddedLdapAutoConfiguration implements DisposableBean { if (StringUtils.hasText(username) && StringUtils.hasText(password)) { config.addAdditionalBindCredentials(username, password); } + config.setListenerConfigs(createListenerConfig(sslBundles)); setSchema(config); - InMemoryListenerConfig listenerConfig = InMemoryListenerConfig.createLDAPConfig("LDAP", - this.embeddedProperties.getPort()); - config.setListenerConfigs(listenerConfig); this.server = new InMemoryDirectoryServer(config); importLdif(this.server, applicationContext); this.server.startListening(); @@ -110,6 +118,27 @@ public final class EmbeddedLdapAutoConfiguration implements DisposableBean { return this.server; } + private InMemoryListenerConfig createListenerConfig(ObjectProvider sslBundles) throws LDAPException { + SslBundle sslBundle = getSslBundle(sslBundles.getIfAvailable()); + if (sslBundle != null) { + SSLContext sslContext = sslBundle.createSslContext(); + SSLServerSocketFactory serverSocketFactory = sslContext.getServerSocketFactory(); + SSLSocketFactory clientSocketFactory = sslContext.getSocketFactory(); + return InMemoryListenerConfig.createLDAPSConfig("LDAPS", null, this.embeddedProperties.getPort(), + serverSocketFactory, clientSocketFactory); + } + return InMemoryListenerConfig.createLDAPConfig("LDAP", this.embeddedProperties.getPort()); + } + + private @Nullable SslBundle getSslBundle(@Nullable SslBundles sslBundles) { + Ssl ssl = this.embeddedProperties.getSsl(); + if (ssl.isEnabled() && StringUtils.hasLength(ssl.getBundle())) { + Assert.notNull(sslBundles, "SSL bundle name has been set but no SSL bundles found in context"); + return sslBundles.getBundle(ssl.getBundle()); + } + return null; + } + private void setSchema(InMemoryDirectoryServerConfig config) { if (!this.embeddedProperties.getValidation().isEnabled()) { config.setSchema(null); diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapProperties.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapProperties.java index d348491af48..c43c72237db 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapProperties.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapProperties.java @@ -62,6 +62,11 @@ public class EmbeddedLdapProperties { */ private final Validation validation = new Validation(); + /** + * SSL configuration. + */ + private final Ssl ssl = new Ssl(); + public int getPort() { return this.port; } @@ -98,6 +103,10 @@ public class EmbeddedLdapProperties { return this.validation; } + public Ssl getSsl() { + return this.ssl; + } + public static class Credential { /** @@ -132,6 +141,37 @@ public class EmbeddedLdapProperties { } + public static class Ssl { + + /** + * Whether to enable SSL support. Enabled automatically if "bundle" is provided + * unless specified otherwise. + */ + private @Nullable Boolean enabled; + + /** + * SSL bundle name. + */ + private @Nullable String bundle; + + public boolean isEnabled() { + return (this.enabled != null) ? this.enabled : this.bundle != null; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getBundle() { + return this.bundle; + } + + public void setBundle(@Nullable String bundle) { + this.bundle = bundle; + } + + } + public static class Validation { /** diff --git a/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfigurationTests.java b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfigurationTests.java index 14b4d020638..9830effb7ab 100644 --- a/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfigurationTests.java +++ b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/embedded/EmbeddedLdapAutoConfigurationTests.java @@ -20,8 +20,11 @@ import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; import com.unboundid.ldap.listener.InMemoryDirectoryServer; +import com.unboundid.ldap.listener.InMemoryListenerConfig; import com.unboundid.ldap.sdk.BindResult; import com.unboundid.ldap.sdk.DN; import com.unboundid.ldap.sdk.LDAPConnection; @@ -32,6 +35,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.ldap.autoconfigure.LdapAutoConfiguration; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -54,7 +58,7 @@ import static org.assertj.core.api.Assertions.assertThat; class EmbeddedLdapAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(EmbeddedLdapAutoConfiguration.class)); + .withConfiguration(AutoConfigurations.of(EmbeddedLdapAutoConfiguration.class, SslAutoConfiguration.class)); @Test void testSetDefaultPort() { @@ -63,6 +67,8 @@ class EmbeddedLdapAutoConfigurationTests { .run((context) -> { InMemoryDirectoryServer server = context.getBean(InMemoryDirectoryServer.class); assertThat(server.getListenPort()).isEqualTo(1234); + InMemoryListenerConfig config = server.getConfig().getListenerConfigs().get(0); + assertThat(config.getListenerName()).isEqualTo("LDAP"); }); } @@ -348,6 +354,63 @@ class EmbeddedLdapAutoConfigurationTests { }); } + @Test + void whenSslBundleIsConfiguredLdapsListenerIsConfigured() { + List propertyValues = new ArrayList<>(); + String location = "classpath:org/springframework/boot/ldap/autoconfigure/embedded/"; + propertyValues.add("spring.ssl.bundle.jks.test.keystore.password=secret"); + propertyValues.add("spring.ssl.bundle.jks.test.keystore.location=" + location + "test.jks"); + propertyValues.add("spring.ssl.bundle.jks.test.truststore.location=" + location + "test.jks"); + propertyValues.add("spring.ssl.bundle.jks.test.protocol=TLSv1.2"); + propertyValues.add("spring.ldap.embedded.port:0"); + propertyValues.add("spring.ldap.embedded.base-dn:dc=spring,dc=org"); + propertyValues.add("spring.ldap.embedded.ssl.bundle:test"); + this.contextRunner.withPropertyValues(propertyValues.toArray(String[]::new)).run((context) -> { + InMemoryDirectoryServer server = context.getBean(InMemoryDirectoryServer.class); + assertThat(server.getConfig().getListenerConfigs().size()).isEqualTo(1); + InMemoryListenerConfig config = server.getConfig().getListenerConfigs().get(0); + assertThat(config.getListenerName()).isEqualTo("LDAPS"); + assertThat(server.getConnection("LDAPS").getSSLSession()).isNotNull(); + }); + } + + @Test + void whenSslBundleIsConfiguredButSslIsDisabledLdapListenerIsConfigured() { + List propertyValues = new ArrayList<>(); + String location = "classpath:org/springframework/boot/ldap/autoconfigure/embedded/"; + propertyValues.add("spring.ssl.bundle.jks.test.keystore.password=secret"); + propertyValues.add("spring.ssl.bundle.jks.test.keystore.location=" + location + "test.jks"); + propertyValues.add("spring.ssl.bundle.jks.test.truststore.location=" + location + "test.jks"); + propertyValues.add("spring.ssl.bundle.jks.test.protocol=TLSv1.2"); + propertyValues.add("spring.ldap.embedded.port:0"); + propertyValues.add("spring.ldap.embedded.base-dn:dc=spring,dc=org"); + propertyValues.add("spring.ldap.embedded.ssl.enabled:false"); + propertyValues.add("spring.ldap.embedded.ssl.bundle:test"); + this.contextRunner.withPropertyValues(propertyValues.toArray(String[]::new)).run((context) -> { + InMemoryDirectoryServer server = context.getBean(InMemoryDirectoryServer.class); + assertThat(server.getConfig().getListenerConfigs().size()).isEqualTo(1); + InMemoryListenerConfig config = server.getConfig().getListenerConfigs().get(0); + assertThat(config.getListenerName()).isEqualTo("LDAP"); + }); + } + + @Test + void whenInvalidSslBundleIsConfiguredThenStartFails() { + List propertyValues = new ArrayList<>(); + String location = "classpath:org/springframework/boot/ldap/autoconfigure/embedded/"; + propertyValues.add("spring.ssl.bundle.jks.test.keystore.password=secret"); + propertyValues.add("spring.ssl.bundle.jks.test.keystore.location=" + location + "test.jks"); + propertyValues.add("spring.ldap.embedded.port:0"); + propertyValues.add("spring.ldap.embedded.base-dn:dc=spring,dc=org"); + propertyValues.add("spring.ldap.embedded.ssl.enabled:true"); + propertyValues.add("spring.ldap.embedded.ssl.bundle:foo"); + this.contextRunner.withPropertyValues(propertyValues.toArray(String[]::new)).run((context) -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure().hasMessageContaining("foo"); + assertThat(context).getFailure().hasMessageContaining("cannot be found"); + }); + } + @Configuration(proxyBeanMethods = false) static class LdapClientConfiguration { diff --git a/module/spring-boot-ldap/src/test/resources/org/springframework/boot/ldap/autoconfigure/embedded/test.jks b/module/spring-boot-ldap/src/test/resources/org/springframework/boot/ldap/autoconfigure/embedded/test.jks new file mode 100644 index 00000000000..8413be81095 Binary files /dev/null and b/module/spring-boot-ldap/src/test/resources/org/springframework/boot/ldap/autoconfigure/embedded/test.jks differ