diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfiguration.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfiguration.java index efc562bef5a9..75805297466c 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfiguration.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfiguration.java @@ -28,6 +28,9 @@ import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.boot.ldap.autoconfigure.LdapProperties.Template; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.ldap.core.support.DefaultTlsDirContextAuthenticationStrategy; +import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; import org.springframework.ldap.convert.ConverterUtils; @@ -53,9 +56,12 @@ public final class LdapAutoConfiguration { @Bean @ConditionalOnMissingBean(LdapConnectionDetails.class) - PropertiesLdapConnectionDetails propertiesLdapConnectionDetails(LdapProperties properties, - Environment environment) { - return new PropertiesLdapConnectionDetails(properties, environment); + PropertiesLdapConnectionDetails propertiesLdapConnectionDetails( + LdapProperties properties, + Environment environment, + ObjectProvider sslBundles) { + return new PropertiesLdapConnectionDetails( + properties, environment, sslBundles.getIfAvailable()); } @Bean @@ -63,7 +69,23 @@ PropertiesLdapConnectionDetails propertiesLdapConnectionDetails(LdapProperties p LdapContextSource ldapContextSource(LdapConnectionDetails connectionDetails, LdapProperties properties, ObjectProvider dirContextAuthenticationStrategy) { LdapContextSource source = new LdapContextSource(); - dirContextAuthenticationStrategy.ifUnique(source::setAuthenticationStrategy); + DirContextAuthenticationStrategy uniqueStrategy = dirContextAuthenticationStrategy.getIfUnique(); + if (uniqueStrategy != null) { + // Exactly one custom strategy bean → use it + source.setAuthenticationStrategy(uniqueStrategy); + } + else if (!dirContextAuthenticationStrategy.stream().findAny().isPresent()) { + // No custom strategy beans at all → apply SSL bundle if configured + SslBundle sslBundle = connectionDetails.getSslBundle(); + if (sslBundle != null) { + DefaultTlsDirContextAuthenticationStrategy tlsStrategy = + new DefaultTlsDirContextAuthenticationStrategy(); + tlsStrategy.setSslSocketFactory(sslBundle.createSslContext().getSocketFactory()); + source.setAuthenticationStrategy(tlsStrategy); + } + // else: no SSL bundle → retain built-in SimpleDirContextAuthenticationStrategy from constructor + } + // else: multiple custom strategy beans → retain built-in SimpleDirContextAuthenticationStrategy PropertyMapper propertyMapper = PropertyMapper.get(); propertyMapper.from(connectionDetails.getUsername()).to(source::setUserDn); propertyMapper.from(connectionDetails.getPassword()).to(source::setPassword); diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapConnectionDetails.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapConnectionDetails.java index 334e3e0a7e16..2471533951be 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapConnectionDetails.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapConnectionDetails.java @@ -19,6 +19,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; +import org.springframework.boot.ssl.SslBundle; /** * Details required to establish a connection to an LDAP service. @@ -58,4 +59,12 @@ public interface LdapConnectionDetails extends ConnectionDetails { return null; } + /** + * SSL bundle to use to establish the LDAP connection. + * @return the SSL bundle to use, or {@code null} if none + */ + default @Nullable SslBundle getSslBundle() { + return null; + } + } diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapProperties.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapProperties.java index 9bbb9bde845d..01db1e745e05 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapProperties.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapProperties.java @@ -27,6 +27,7 @@ import org.springframework.ldap.core.LdapTemplate; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; /** * Configuration properties for LDAP. @@ -78,6 +79,8 @@ public class LdapProperties { private final Template template = new Template(); + private final Ssl ssl = new Ssl(); + public String @Nullable [] getUrls() { return this.urls; } @@ -134,6 +137,10 @@ public Template getTemplate() { return this.template; } + public Ssl getSsl() { + return this.ssl; + } + public String[] determineUrls(Environment environment) { if (ObjectUtils.isEmpty(this.urls)) { return new String[] { "ldap://localhost:" + determinePort(environment) }; @@ -199,6 +206,35 @@ public void setIgnoreSizeLimitExceededException(Boolean ignoreSizeLimitExceededE } + /** + * SSL configuration. + */ + public static class Ssl { + + /** + * SSL bundle name. + */ + private @Nullable String bundle; + + public @Nullable String getBundle() { + return this.bundle; + } + + public void setBundle(@Nullable String bundle) { + this.bundle = bundle; + } + + /** + * Returns whether SSL is enabled. SSL is considered enabled if a bundle name + * has been set. + * @return whether SSL is enabled + */ + public boolean determineEnabled() { + return StringUtils.hasText(this.bundle); + } + + } + /** * Define the methods to handle referrals. */ diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/PropertiesLdapConnectionDetails.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/PropertiesLdapConnectionDetails.java index e419570f6677..1ac4a6794c4d 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/PropertiesLdapConnectionDetails.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/PropertiesLdapConnectionDetails.java @@ -18,7 +18,11 @@ import org.jspecify.annotations.Nullable; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; import org.springframework.core.env.Environment; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * Adapts {@link LdapProperties} to {@link LdapConnectionDetails}. @@ -31,9 +35,13 @@ class PropertiesLdapConnectionDetails implements LdapConnectionDetails { private final Environment environment; - PropertiesLdapConnectionDetails(LdapProperties properties, Environment environment) { + private final @Nullable SslBundles sslBundles; + + PropertiesLdapConnectionDetails(LdapProperties properties, Environment environment, + @Nullable SslBundles sslBundles) { this.properties = properties; this.environment = environment; + this.sslBundles = sslBundles; } @Override @@ -56,4 +64,17 @@ public String[] getUrls() { return this.properties.getPassword(); } + @Override + public @Nullable SslBundle getSslBundle() { + LdapProperties.Ssl ssl = this.properties.getSsl(); + if (!ssl.determineEnabled()) { + return null; + } + if (StringUtils.hasLength(ssl.getBundle())) { + Assert.notNull(this.sslBundles, "SSL bundle name has been set but no SSL bundles found in context"); + return this.sslBundles.getBundle(ssl.getBundle()); + } + return null; + } + } diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/LLdapContainerConnectionDetailsFactory.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/LLdapContainerConnectionDetailsFactory.java index c7d3f3d34e25..747d625977db 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/LLdapContainerConnectionDetailsFactory.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/LLdapContainerConnectionDetailsFactory.java @@ -16,9 +16,12 @@ package org.springframework.boot.ldap.testcontainers; +import org.jspecify.annotations.Nullable; + import org.testcontainers.ldap.LLdapContainer; import org.springframework.boot.ldap.autoconfigure.LdapConnectionDetails; +import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; @@ -64,6 +67,11 @@ public String getPassword() { return getContainer().getPassword(); } + @Override + public @Nullable SslBundle getSslBundle() { + return super.getSslBundle(); + } + } } diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/OpenLdapContainerConnectionDetailsFactory.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/OpenLdapContainerConnectionDetailsFactory.java index a263712b0648..cc7dba53cee9 100644 --- a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/OpenLdapContainerConnectionDetailsFactory.java +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/testcontainers/OpenLdapContainerConnectionDetailsFactory.java @@ -20,10 +20,13 @@ import java.util.Map; import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; + import org.testcontainers.containers.Container; import org.testcontainers.containers.GenericContainer; import org.springframework.boot.ldap.autoconfigure.LdapConnectionDetails; +import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; @@ -84,6 +87,11 @@ public String getPassword() { return getContainer().getEnvMap().getOrDefault("LDAP_ADMIN_PASSWORD", "admin"); } + @Override + public @Nullable SslBundle getSslBundle() { + return super.getSslBundle(); + } + } } diff --git a/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfigurationTests.java b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfigurationTests.java index 18ae1664608e..65b376e5d26d 100644 --- a/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfigurationTests.java +++ b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfigurationTests.java @@ -20,6 +20,7 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.jspecify.annotations.Nullable; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.convert.ApplicationConversionService; @@ -35,8 +36,13 @@ import org.springframework.ldap.pool2.factory.PoolConfig; import org.springframework.ldap.pool2.factory.PooledContextSource; import org.springframework.ldap.support.LdapUtils; - +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import org.springframework.ldap.core.support.DefaultTlsDirContextAuthenticationStrategy; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; import static org.mockito.Mockito.mock; /** @@ -199,13 +205,52 @@ void templateConfigurationCanBeCustomized() { assertThat(ldapTemplate).hasFieldOrPropertyWithValue("ignoreSizeLimitExceededException", false); }); } + @Test + void contextSourceWithSslBundleAndCustomDirContextAuthenticationStrategyUsesCustomStrategy() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + when(sslBundles.getBundle("test")).thenReturn(sslBundle); + + DirContextAuthenticationStrategy customStrategy = mock(DirContextAuthenticationStrategy.class); + + this.contextRunner + .withPropertyValues("spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .withBean(DirContextAuthenticationStrategy.class, () -> customStrategy) + .run((context) -> { + LdapContextSource contextSource = context.getBean(LdapContextSource.class); + assertThat(contextSource).extracting("authenticationStrategy").isSameAs(customStrategy); + }); + } + @Test + void contextSourceWithSslBundleUsesDefaultTlsAuthenticationStrategy() { + SslBundle sslBundle = mock(SslBundle.class); + SSLContext sslContext = mock(SSLContext.class); + SSLSocketFactory socketFactory = mock(SSLSocketFactory.class); + + when(sslBundle.createSslContext()).thenReturn(sslContext); + when(sslContext.getSocketFactory()).thenReturn(socketFactory); + + SslBundles sslBundles = mock(SslBundles.class); + when(sslBundles.getBundle("test")).thenReturn(sslBundle); + this.contextRunner + .withPropertyValues("spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> { + LdapContextSource contextSource = context.getBean(LdapContextSource.class); + assertThat(contextSource).extracting("authenticationStrategy") + .isInstanceOf(DefaultTlsDirContextAuthenticationStrategy.class) + .extracting("sslSocketFactory") + .isSameAs(socketFactory); + }); + } @Test - void contextSourceWithUserProvidedPooledContextSource() { - this.contextRunner.withUserConfiguration(PooledContextSourceConfig.class).run((context) -> { + void contextSourceWithoutSslBundleDoesNotConfigureAuthenticationStrategy() { + this.contextRunner.run((context) -> { LdapContextSource contextSource = context.getBean(LdapContextSource.class); - assertThat(contextSource.getUrls()).containsExactly("ldap://localhost:389"); - assertThat(contextSource.isAnonymousReadOnly()).isTrue(); + assertThat(contextSource).extracting("authenticationStrategy") + .isInstanceOf(SimpleDirContextAuthenticationStrategy.class); }); } @@ -235,6 +280,28 @@ void contextSourceWithCustomNonUniqueDirContextAuthenticationStrategy() { }); } + @Test + void contextSourceWithSslBundleAndMultipleCustomStrategiesUsesDefault() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + when(sslBundles.getBundle("test")).thenReturn(sslBundle); + + this.contextRunner + .withPropertyValues("spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .withUserConfiguration(CustomDirContextAuthenticationStrategy.class, + AnotherCustomDirContextAuthenticationStrategy.class) + .run((context) -> { + assertThat(context).hasBean("customDirContextAuthenticationStrategy") + .hasBean("anotherCustomDirContextAuthenticationStrategy"); + LdapContextSource contextSource = context.getBean(LdapContextSource.class); + assertThat(contextSource).extracting("authenticationStrategy") + .isNotSameAs(context.getBean("customDirContextAuthenticationStrategy")) + .isNotSameAs(context.getBean("anotherCustomDirContextAuthenticationStrategy")) + .isInstanceOf(SimpleDirContextAuthenticationStrategy.class); + }); + } + @Configuration(proxyBeanMethods = false) static class ConnectionDetailsConfiguration { @@ -261,6 +328,11 @@ public String getUsername() { public String getPassword() { return "ldap-password"; } + + @Override + public @Nullable SslBundle getSslBundle() { + return null; + } }; }