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 25df9e9104c..08f3760d7c3 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 @@ -703,6 +703,40 @@ Make sure to flag your customized javadoc:org.springframework.ldap.core.ContextS +[[data.nosql.ldap.ssl]] +=== SSL + +To connect using LDAPS, configure the xref:features/ssl.adoc[SSL bundle] to use by setting the configprop:spring.ldap.ssl.bundle[] property, and use an `ldaps://` URL in the configprop:spring.ldap.urls[] property, as shown in the following example: + +[configprops,yaml] +---- +spring: + ldap: + urls: "ldaps://myserver:636" + ssl: + bundle: "example" +---- + +Setting configprop:spring.ldap.ssl.bundle[] also switches the default URL to `ldaps://localhost:636`, so configprop:spring.ldap.urls[] only has to be set when connecting to a different host or port. + +NOTE: SSL is only supported when connecting over `ldaps://`. Enabling SSL while any of the configured URLs uses a plain `ldap://` scheme results in a startup failure. + +These properties do not configure StartTLS, which upgrades a plain `ldap://` connection rather than using LDAPS, and which therefore cannot be combined with an SSL bundle. +To use StartTLS, define a javadoc:org.springframework.ldap.core.support.DirContextAuthenticationStrategy[] bean such as javadoc:org.springframework.ldap.core.support.DefaultTlsDirContextAuthenticationStrategy[] and set the SSL socket factory to use on it. + +If you need SSL support without customizing the trust and key material, set the configprop:spring.ldap.ssl.enabled[] property to `true` instead of providing a bundle. +This uses the platform's default trust and key material. + +WARNING: These properties only apply to the auto-configured javadoc:org.springframework.ldap.core.support.LdapContextSource[]. +If you define your own javadoc:org.springframework.ldap.core.support.LdapContextSource[] bean, they are ignored and you have to configure SSL yourself. +If you define your own javadoc:org.springframework.boot.ldap.autoconfigure.LdapConnectionDetails[] bean, including one contributed by a `@ServiceConnection`, they are ignored as well and the auto-configured context source uses the SSL bundle provided by that bean instead. +An `ldaps://` connection that has not been given any key or trust material silently uses the platform's default rather than failing. + +TIP: These properties configure the LDAP client. +To enable SSL on the xref:data/nosql.adoc#data.nosql.ldap.embedded.ssl[embedded LDAP server], use the separate configprop:spring.ldap.embedded.ssl.bundle[] property. + + + [[data.nosql.ldap.repositories]] === Spring Data LDAP Repositories diff --git a/module/spring-boot-ldap/src/dockerTest/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfigurationSslIntegrationTests.java b/module/spring-boot-ldap/src/dockerTest/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfigurationSslIntegrationTests.java new file mode 100644 index 00000000000..1d78ea8030a --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/java/org/springframework/boot/ldap/autoconfigure/LdapAutoConfigurationSslIntegrationTests.java @@ -0,0 +1,181 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.ldap.autoconfigure; + +import java.security.cert.CertPathBuilderException; +import java.security.cert.CertificateException; +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testcontainers.service.connection.PemTrustStore; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testcontainers.service.connection.Ssl; +import org.springframework.boot.testsupport.container.OpenLdapContainer; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.annotation.Configuration; +import org.springframework.ldap.core.AttributesMapper; +import org.springframework.ldap.core.LdapTemplate; +import org.springframework.ldap.query.LdapQueryBuilder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; + +/** + * Integration tests for SSL bundle support in {@link LdapAutoConfiguration}, using a real + * OpenLDAP server configured for LDAPS. + * + * @author Moritz Halbritter + */ +@Testcontainers(disabledWithoutDocker = true) +class LdapAutoConfigurationSslIntegrationTests { + + private static final String CERTIFICATES = "org/springframework/boot/ldap/autoconfigure/ssl/"; + + private static final String CA_CERTIFICATE = "classpath:" + CERTIFICATES + "ca.crt"; + + // Certificates issued to 'other.example.com' rather than to the host the container is + // reached on, used to check that the hostname is verified. + private static final String OTHER_HOST_CERTIFICATES = CERTIFICATES + "otherhost/"; + + private static final String OTHER_HOST_CA_CERTIFICATE = "classpath:" + CERTIFICATES + "otherhost/ca.crt"; + + // Without a connect timeout, the SSL handshake is performed lazily on the first + // write, and a certificate validation failure then surfaces as a generic + // SocketException instead of the underlying cause. + private static final String CONNECT_TIMEOUT_PROPERTY = "spring.ldap.baseEnvironment.com.sun.jndi.ldap.connect.timeout=5000"; + + private static OpenLdapContainer openLdapContainer() { + return openLdapContainer(CERTIFICATES); + } + + private static OpenLdapContainer openLdapContainer(String certificates) { + OpenLdapContainer container = TestImage.container(OpenLdapContainer.class); + container.addExposedPorts(636); + return container.withEnv("LDAP_TLS_VERIFY_CLIENT", "never") + // The image restarts slapd internally once the TLS config has been applied, + // so the default "port is listening" wait strategy resolves too early. + .waitingFor(Wait.forLogMessage(".*slapd starting.*\\n", 1)) + .withCopyFileToContainer(MountableFile.forClasspathResource(certificates + "server.crt"), + "/container/service/slapd/assets/certs/ldap.crt") + .withCopyFileToContainer(MountableFile.forClasspathResource(certificates + "server.key"), + "/container/service/slapd/assets/certs/ldap.key") + .withCopyFileToContainer(MountableFile.forClasspathResource(certificates + "ca.crt"), + "/container/service/slapd/assets/certs/ca.crt"); + } + + @Nested + @SpringJUnitConfig + @TestPropertySource(properties = CONNECT_TIMEOUT_PROPERTY) + class WhenServerCertificateIsTrusted { + + @Ssl + @PemTrustStore(CA_CERTIFICATE) + @Container + @ServiceConnection + static final OpenLdapContainer openLdap = openLdapContainer(); + + @Autowired + private LdapTemplate ldapTemplate; + + @Test + void shouldSearchOverLdaps() { + List dc = this.ldapTemplate.search(LdapQueryBuilder.query().where("objectclass").is("dcObject"), + (AttributesMapper) (attributes) -> attributes.get("dc").get().toString()); + assertThat(dc).singleElement().isEqualTo("example"); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(LdapAutoConfiguration.class) + static class TestConfiguration { + + } + + } + + @Nested + class WhenServerCertificateIsNotTrusted { + + @Container + private final OpenLdapContainer openLdap = openLdapContainer(); + + @Test + void shouldFailToSearchOverLdaps() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LdapAutoConfiguration.class, SslAutoConfiguration.class)) + .withPropertyValues( + "spring.ldap.urls:ldaps://" + this.openLdap.getHost() + ":" + this.openLdap.getMappedPort(636), + "spring.ldap.base:dc=example,dc=org", "spring.ldap.username:cn=admin,dc=example,dc=org", + "spring.ldap.password:admin", "spring.ldap.ssl.enabled:true", CONNECT_TIMEOUT_PROPERTY); + contextRunner.run((context) -> { + LdapTemplate ldapTemplate = context.getBean(LdapTemplate.class); + assertThatException() + .isThrownBy(() -> ldapTemplate.search(LdapQueryBuilder.query().where("objectclass").is("dcObject"), + (AttributesMapper) (attributes) -> attributes.get("dc").get().toString())) + .withRootCauseInstanceOf(CertPathBuilderException.class); + }); + } + + } + + @Nested + @SpringJUnitConfig + @TestPropertySource(properties = CONNECT_TIMEOUT_PROPERTY) + class WhenServerCertificateIsForADifferentHost { + + @Ssl + @PemTrustStore(OTHER_HOST_CA_CERTIFICATE) + @Container + @ServiceConnection + static final OpenLdapContainer openLdap = openLdapContainer(OTHER_HOST_CERTIFICATES); + + @Autowired + private LdapTemplate ldapTemplate; + + @Test + void shouldFailToSearchOverLdaps() { + // The certificate chain is trusted, so reaching the server on a host the + // certificate has not been issued to can only fail on hostname verification. + assertThatException() + .isThrownBy(() -> this.ldapTemplate.search(LdapQueryBuilder.query().where("objectclass").is("dcObject"), + (AttributesMapper) (attributes) -> attributes.get("dc").get().toString())) + .withRootCauseInstanceOf(CertificateException.class) + .havingRootCause() + .withMessageContaining(openLdap.getHost()); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(LdapAutoConfiguration.class) + static class TestConfiguration { + + } + + } + +} diff --git a/module/spring-boot-ldap/src/dockerTest/java/org/springframework/boot/ldap/testcontainers/LLdapContainerConnectionDetailsFactorySslIntegrationTests.java b/module/spring-boot-ldap/src/dockerTest/java/org/springframework/boot/ldap/testcontainers/LLdapContainerConnectionDetailsFactorySslIntegrationTests.java new file mode 100644 index 00000000000..acc23255719 --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/java/org/springframework/boot/ldap/testcontainers/LLdapContainerConnectionDetailsFactorySslIntegrationTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.ldap.testcontainers; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.ldap.LLdapContainer; +import org.testcontainers.utility.MountableFile; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.ldap.autoconfigure.LdapAutoConfiguration; +import org.springframework.boot.testcontainers.service.connection.PemTrustStore; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testcontainers.service.connection.Ssl; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.annotation.Configuration; +import org.springframework.ldap.core.AttributesMapper; +import org.springframework.ldap.core.LdapTemplate; +import org.springframework.ldap.query.LdapQueryBuilder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the SSL bundle support of {@link LLdapContainerConnectionDetailsFactory}, + * using an LLDAP container configured for LDAPS. + * + * @author Moritz Halbritter + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +class LLdapContainerConnectionDetailsFactorySslIntegrationTests { + + private static final String CERTIFICATES = "org/springframework/boot/ldap/autoconfigure/ssl/"; + + private static final int LDAPS_PORT = 6360; + + @Ssl + @PemTrustStore("classpath:" + CERTIFICATES + "ca.crt") + @Container + @ServiceConnection + static final LLdapContainer lldap = ldapsContainer(); + + @Autowired + private LdapTemplate ldapTemplate; + + private static LLdapContainer ldapsContainer() { + LLdapContainer container = TestImage.container(LLdapContainer.class); + container.addExposedPorts(LDAPS_PORT); + return container.withEnv("LLDAP_LDAPS_OPTIONS__ENABLED", "true") + .withEnv("LLDAP_LDAPS_OPTIONS__CERT_FILE", "/certs/server.crt") + .withEnv("LLDAP_LDAPS_OPTIONS__KEY_FILE", "/certs/server.key") + .withCopyFileToContainer(MountableFile.forClasspathResource(CERTIFICATES + "server.crt"), + "/certs/server.crt") + .withCopyFileToContainer(MountableFile.forClasspathResource(CERTIFICATES + "server.key"), + "/certs/server.key"); + } + + @Test + void shouldSearchOverLdaps() { + List cn = this.ldapTemplate.search(LdapQueryBuilder.query().where("objectClass").is("inetOrgPerson"), + (AttributesMapper) (attributes) -> attributes.get("cn").get().toString()); + assertThat(cn).singleElement().isEqualTo("Administrator"); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(LdapAutoConfiguration.class) + static class TestConfiguration { + + } + +} diff --git a/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/ca.crt b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/ca.crt new file mode 100644 index 00000000000..6b39d52ce64 --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWTCCAkGgAwIBAgIURFIQf/P6N/1OEFidlxuqbi3DOp4wDQYJKoZIhvcNAQEL +BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm +aWNhdGUgQXV0aG9yaXR5MCAXDTI2MDgyNTEyNDg0MFoYDzIxMjYwODAxMTI0ODQw +WjA7MRkwFwYDVQQKDBBTcHJpbmcgQm9vdCBUZXN0MR4wHAYDVQQDDBVDZXJ0aWZp +Y2F0ZSBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9 +IJLbjjsaH7VWjlPcjHLOEptcdlWGIChS87Y2bmWgxsYw0W6HjulSFIRHUsI6/NsJ +WH5lodrBaKVm23zqejxnPwzijR7x6tMnNYp8Hm2uy700h5AwMS+1tDCEOPIQympZ +h7FlVCRLkNUjPNWLcVNwlZZlzSXb9Ql58zyp15/ytkO7W0Pv/VTXXhBMQfvwnN2Y +TJIY7Mxq61W4Jo0JnYpZslbD1sWno4cceVXrVoRmEWYd89J844ItlUmheJdy0GIP +xVBpM1Ihe6beAx8AG83kDOi6+KM0liUg5LXORDpfKdfhxLSghbCxkW0VDrYXdsGa +oYeOeTsM/oHHgjQ4blH9AgMBAAGjUzBRMB0GA1UdDgQWBBTK6aBvkBA3CttNNWPd +SA2zqC3J/DAfBgNVHSMEGDAWgBTK6aBvkBA3CttNNWPdSA2zqC3J/DAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBd4FBgLscxlGxQEe0TukF4yZts +c4e/waOjUJk0eNJTFeJTQrJCMqMKI5CKunjAJ1g4+XFKc3fxw9qNXieHjL0ekKLb +hXLzdcA75gRQ4NZwcw4GdZnOb7WaUWq9u5FCGU1NQpMu38Mv56o005iCrV8sEpij +ZsvwAJjFP2Fl//fafhMEsk06EnKbjpfxaB1KZ6DnBrMBeh/OokVnV9Mqom+bB48y +U38dxoNcacAmOCckaqALrCyC8pYFWQqTOGtJGezEdDIK7xmonADjdv5qvl1o3xDV +iRsjC7XaNAvvmzxm1E9JK66FhR22U1zsJp9c72otjjF6dpe8LNYTMm6KCB6/ +-----END CERTIFICATE----- diff --git a/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/ca.crt b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/ca.crt new file mode 100644 index 00000000000..7ff49d8c87d --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWTCCAkGgAwIBAgIUNzW2vCQbExGKXWES4vt2MTJpqfwwDQYJKoZIhvcNAQEL +BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm +aWNhdGUgQXV0aG9yaXR5MCAXDTI2MDgyNjA3NTcwM1oYDzIxMjYwODAyMDc1NzAz +WjA7MRkwFwYDVQQKDBBTcHJpbmcgQm9vdCBUZXN0MR4wHAYDVQQDDBVDZXJ0aWZp +Y2F0ZSBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCU +xr7l1jVuX1W+p1V7NMwv5gZUEBPTTeuTh2pjsuNjdmGj9jxkWwV2WLOi2Vm9oSm7 +IrkLLCMX8xaq5kCgdN90c7bbLKawfMK3gCGvmWDgOHfRvtpDBD+FBn7ZbVQKKjj6 +aKaQoB6bI+53QJzgnK5rI6niC+OFhjJqY57H3GFqTOxMZPpul9QcVO2f1YGOR9Mw +FkRmmi9jYcWC5Aa/VZRPpeBuyFLcR40R/TVsRT9VW1opUukXSDegSCUUS1Ac/k0i +rgGQGFWs+VG/Eyux9nIfZwUx1tE9zMUsr2uB+J8P409sXJUVsYhug8L1qBo3urJ0 +R7Ckdkq2MrSnRoQWr+AfAgMBAAGjUzBRMB0GA1UdDgQWBBR0k8tCwN80BByqjJn5 +XRcDZRGGuzAfBgNVHSMEGDAWgBR0k8tCwN80BByqjJn5XRcDZRGGuzAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAxly0TuONJZO/o1Upq6jQjEWUW +Olq7CiFyhA2xV6gv3biSd6RqJDFPnE29hR8/JAGVWI0nJZPkso5wEFusWPp74py2 +ZT5/erGHd8fTwuRgeFrFa6O+E414xDj+7cI+QgOSdy+Ut5Lg5GrQBsJ/TeyRgrjv +CumWJv59hhj1PAAtkkkUmK24J5V4NZBba6vCmFMERIbRSduNW0+EngqNRjZMdWg0 +V9VofFE2Sal4sIn3RZ+TOAmNMyaQ/Z7+QJs9M3AcHhq/MO/0NcHKVhnkvQUIjeeQ +Ky1IzhKvJOK6e/tzzFXpjEhSFc8fdjbiZJHnXBzTkpKDdQemEOHG5ruOb02H +-----END CERTIFICATE----- diff --git a/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/server.crt b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/server.crt new file mode 100644 index 00000000000..5068e950d85 --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/server.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDcDCCAligAwIBAgIURShLEDUt3TkSK6K64LLC2BfOH6YwDQYJKoZIhvcNAQEL +BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm +aWNhdGUgQXV0aG9yaXR5MCAXDTI2MDgyNjA3NTcwM1oYDzIxMjYwODAyMDc1NzAz +WjA3MRkwFwYDVQQKDBBTcHJpbmcgQm9vdCBUZXN0MRowGAYDVQQDDBFvdGhlci5l +eGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJXF59Ww +lWJRjo+Pun8BqDO/QChI4twNvPgjgdGNzaUy7rbsMelmjgPVfV02iqdq3UOY/vf1 +rGnB3CYXSp5dF+C7zSGoDW3lyvVsxUYkod/CcqLteNwMSutFnDE9ujBcX0kMR7Py +RKidK0unHf96rieIPTA0PSlzf0oodNtuKoy/zn+ug43fYXW3zG5K4IwrN8Xq2or1 +EwxzazWieVm4VUKdzUtJKNlWx88yYNG5IPgBQUzFydvjwRBG+mmDPTQvggiH4zBM +U5VqyXwcukgC6so4YSzeJ5EzPjYICjFhp0xkXUmFtHx2PSCUXwDo6uuQEuFe6oB1 +q+kGf+fJNoChdIcCAwEAAaNuMGwwHAYDVR0RBBUwE4IRb3RoZXIuZXhhbXBsZS5j +b20wDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUohpPNd2Enq4O6nabkWQPFsEmaLAw +HwYDVR0jBBgwFoAUdJPLQsDfNAQcqoyZ+V0XA2URhrswDQYJKoZIhvcNAQELBQAD +ggEBABhW3pHn4a2footFrUq2aSS//1NVG4xkRIrjrQ7V4gfesErzBsY1p1iStTpX +8GZykyTIPH2y039khsuIQoIHWYs3W3Ccu+SmkxVCZzEJVX/6AiyQoEcpX/nwzzcw +R7aptXN2l67hO2w6UnBlrDpykUSvtpsiF0GUHfn1sTD0UVNbiW7SY/KAzol5RJ70 +oRkrJBSLyci7O4wYRD0ILp5y53uZr5Oqij0z/yRZFdlPjivfeWIWm7Q8r0oHVTmJ +NBXhlQl2HmDXTTe9nBQLo4EphU0ypiHqCOgtNN38e8XjHCQrEJRKZlXB5py7ao85 +ecI/rvCeqS6/zEC6mtUnn0Z8kI0= +-----END CERTIFICATE----- diff --git a/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/server.key b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/server.key new file mode 100644 index 00000000000..12b35f55b6b --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/otherhost/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCVxefVsJViUY6P +j7p/Aagzv0AoSOLcDbz4I4HRjc2lMu627DHpZo4D1X1dNoqnat1DmP739axpwdwm +F0qeXRfgu80hqA1t5cr1bMVGJKHfwnKi7XjcDErrRZwxPbowXF9JDEez8kSonStL +px3/eq4niD0wND0pc39KKHTbbiqMv85/roON32F1t8xuSuCMKzfF6tqK9RMMc2s1 +onlZuFVCnc1LSSjZVsfPMmDRuSD4AUFMxcnb48EQRvppgz00L4IIh+MwTFOVasl8 +HLpIAurKOGEs3ieRMz42CAoxYadMZF1JhbR8dj0glF8A6OrrkBLhXuqAdavpBn/n +yTaAoXSHAgMBAAECggEADP4U26kf35GROzInf+qi4fcbSuNXwRzBlQnV5Y0JlxhR +NDXoXOwnZONZc4NoIv6jbY1eJ1OwJNR+YCEkUsYv0uNq97QRkvdpbdtSGbQGOHHK +NzwM5savBE3blaHz/uUxShYlbWswByu4AvrfIISB8XsuFuDRO9rn4rgzuvCJnGZj +h7nM6DzuspY5x17kY/b/V3dq0ytaVQbSMRA0wdR5UXV8uBTlydqL5fyTdpDWNBvr +pK6gmEo64mCfqdhFCrhu64m/NUAAYmIoUn5mEzboy1b/j7+nTqzXs8T/2phA6XkE +SFoEKVJnTmC8Gxmg/1MyWUg2N9dsqZow5paR/LXbcQKBgQDTpSDGYwZpWS6EXAlt +R3dvNsjIHMvg62qsZehjPQq9oZIRy0bvwQ1KnZQWJpNZO7dRX1YkWe5KpesXG209 +bH3C+L/56H/g6d9Oxn+6jvV9xq2CZJR+PJo9ia1HonMF4l00+4MF1xrR4TYheriq +X/rYr5tfU+Kn2pFi9IjkNb1zUwKBgQC1KVAXrKEAM81CWACw5tpcEmdwmuA8JPsX +eFozRXSD/9dPd6aICtE33GI6v7QS6OFRiqp4HOmFoEEH2P65YcD7fft7KowXNRyV +7oijW75fRxh5N4b2MIN2MJ5kjj4snunYbvC8zACzrF96t5/duesRUoUVaU7IbXd3 +zVMQT+enfQKBgA4UhkGshNitXjLgDKCAiKmVc3YctFOaVdZyUSuI/BXxkc4tP+Lj +bQbxxNBUcKkXF/MJ7KkucnP05db7tCDJ/vPhqKPvm9JSvNB41DaNHDfp59Es79QW +JAzEBAsn+48AqELGGMlirh4YZYmEqJRtHjscM7H72rBgEhwLOLA2AwyRAoGBAKcD +jk41HTz1hN8MCJ8ORPMrGGfssJtiMIZjsyyfdJqYy+P8V/AFoPpR19F3mMjyH3+w +ShZv+S3zHuDgQX2gzGxOqgDTqdFfapojDZ8k3m4yOjLOaUMgWKImkm/73v/+BXI+ +XPyBSohaeq/FB8I/O6J9pCmoKzSGDuTIyzI3qEZ1AoGAOEMIJ0ZXzSTo4jA27ur2 +YNspFDlUyzNEDzJ1z6om4dYJr1onO6gQeMuQsUz7BgewLn8nw3d/KzWHOiPgZ6VL +LP45n4GLMWNxYTprmv2cbyEvT5w0W3hcukMBbMCM7bM4skwvwVWDccsgE4WDQk97 +wbIo2v1Z9TajCQjv6ir0w7c= +-----END PRIVATE KEY----- diff --git a/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/server.crt b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/server.crt new file mode 100644 index 00000000000..16146dfd17d --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/server.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDYzCCAkugAwIBAgIUa/9qbWfUPcuXtVnmhGEIpzPRro4wDQYJKoZIhvcNAQEL +BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm +aWNhdGUgQXV0aG9yaXR5MCAXDTI2MDgyNTEyNDg0MFoYDzIxMjYwODAxMTI0ODQw +WjAvMRkwFwYDVQQKDBBTcHJpbmcgQm9vdCBUZXN0MRIwEAYDVQQDDAlsb2NhbGhv +c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMnHAogOOlrX3H8YCU +9U9ubEFhJIiABCMA7itTkXCxshsziqooNFPW037MVUCEvqfXtkHy0GJXjaYLyyHj +6NQJJs1NYaC05okEn5f0f6+lxVNSOk9TjHxEbXdOMTZAOiAmwsRXTReb185jMOxq +xUlLSY3ehot8geB4SjGNLQOKg+mCznt9f21SGi7o8TaNbTElDWqWIlCVDQ3AYsrm +RRRSXY9DIdpgHwRrnzYoS57+m7BjYUyx0kgamT8FehK4fa+BhIGoIvLAsWp1nd15 +2YgQBrg82zDemT2zf5PwnOrRqTiJUubJEotjeOII5mqMdyKnrlpZ41FcK2oRFwvt +KDdtAgMBAAGjaTBnMAkGA1UdEwQCMAAwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/ +AAABMB0GA1UdDgQWBBTIoMeEZxAks2Q5D319xxUMln2noDAfBgNVHSMEGDAWgBTK +6aBvkBA3CttNNWPdSA2zqC3J/DANBgkqhkiG9w0BAQsFAAOCAQEAostmB27rtPXi +0hX46UiQ84tcr0ViwK7TT4BHYr0ZntkOj1hKjpfjMORN1ANTYBLGpSsSM2xLBB2g +VXPLCo3BGU1rjxQ7rI3OKZ910pfwf2uvX6LDLRYjKjVnr6pElS3jSVw/nVvYRMf8 +acjP7KBgGSLZyqizccSWYTsCYy+V56C4IJTanqf1sHRTqLsICcnwIpoSGwWBv9cG +kFsFoWE+tPwDGn7QuejJ+57U6m23E1vaDNwvi/xrsNy6tfVYS7OF+DzuDR8mHab/ +Qvx0qzJkpeYziaObn43ocnd3vn5vhf54UYZkK5eieRnXubfsIYA1u2igIdIAVRk7 +NPRHOlFk+g== +-----END CERTIFICATE----- diff --git a/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/server.key b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/server.key new file mode 100644 index 00000000000..8e958b351c6 --- /dev/null +++ b/module/spring-boot-ldap/src/dockerTest/resources/org/springframework/boot/ldap/autoconfigure/ssl/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDMnHAogOOlrX3H +8YCU9U9ubEFhJIiABCMA7itTkXCxshsziqooNFPW037MVUCEvqfXtkHy0GJXjaYL +yyHj6NQJJs1NYaC05okEn5f0f6+lxVNSOk9TjHxEbXdOMTZAOiAmwsRXTReb185j +MOxqxUlLSY3ehot8geB4SjGNLQOKg+mCznt9f21SGi7o8TaNbTElDWqWIlCVDQ3A +YsrmRRRSXY9DIdpgHwRrnzYoS57+m7BjYUyx0kgamT8FehK4fa+BhIGoIvLAsWp1 +nd152YgQBrg82zDemT2zf5PwnOrRqTiJUubJEotjeOII5mqMdyKnrlpZ41FcK2oR +FwvtKDdtAgMBAAECggEAJZnEBLV24C3rNPCdBAK2l6C+PQdVLE1WT2f1P1S1ZHpK +BGVBksTW+V4BabRq+Fn/ByWfpesEf3lw367zKepR+OKQDN3ZhhTtbhOT/aLvWOPT +xYMgq8Zf2cVSDOT/RrtHyEe3hCGPKpz0eQb3E0HxD6xTLWcFQBqv8PsVcw+oHG6A +CFFMWDYAYGbyIpwXpqPVT1YYxOc1taq+Zr2UAN4bK9hFmNv19bAqasCPWveStFiS +oNH4cdyW5Jbss6ZVXeDRZ7NyGpARGLxNJvAKCuQglgIsn7oV2Z3niBX/pGSs3yXI +ryP+pL3iESZPEjGkTrRy+HxWURI/fiOtLKUK/p0vMQKBgQD11Fx/KZC+k6ZSVvMD +nsppLZM5vqSEz4FJqmTHgSF0A2zX+gqM9w7ckhA/hi6PWFzTWBqGXI5FQ2tjKcZc +APQLcS1BrRTa2ZZK8bmVi0X1GJnWG1mqLcYolyv2X9BULhEXM5biEvM0o46pps9s +PVZGR0vlvcENm3JjUgL2zs3qaQKBgQDVE4XAI/iIc+vUzMhyaLt18hyxW3No7r3a +3MKYxlTH0G9MX9AQ2RzoPf6vIx6REenRUBdxJu/XaQBHd291oC8Hq5xIqefUJFY6 +hCdCi4IJw9SvgCJshRsIJP+giH3pztj0br/RNoqqd4IPTisMdVoyKFiz/Mjm4vPQ +hoLIQKVcZQKBgQCfuamX3hP0H89TdLdVRNlTWY9tV5dhy8m/aX0tu5NW49rWwdoV +GXmIi0cPX0nlTY6Sq44gewbdrh6aQcxmfyASRykWoTUJtZLXgxQIPIPp6mAaI48w +6aj1OrQP0tVzvLLSFm0U2yc2robFaGMhewERjMWdsps7EiNSsTjH0Dsu8QKBgE/H +YXrRfQrKLHCexCZiJF5C6o9VaF7PlPJPWeUNzUyLNEDqVuMYB4TFQYido6I5jMw8 +KqrJI2AKBnq7s1XdRf6fOILUauK5QDfkpzZ5OZUiZ43Wcg0jwT1JbRTURiC2u/C5 +TsSsoTq2SFImuGOPxNem1598dFROgW+ADZOg8MkpAoGAVOEKtRblYtHBpBI1itvy +3AOIeaExQ49TLv9/fTJW9pY4j/47getq7RklU2uJ7FyV6ORxfaoQPJwSyTM47dNW +PPo0sbiw2iGlzcoG0A7Yf7/iffuvFj9qPZEzZNczBBmwE273zmgEN/royqKEeDSV +Czgn7Jeanqq0SBLGiJ9TPog= +-----END PRIVATE KEY----- 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 75805297466..ec9b0c9b46b 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 @@ -16,9 +16,17 @@ package org.springframework.boot.ldap.autoconfigure; +import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -27,11 +35,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.convert.ApplicationConversionService; +import org.springframework.boot.ldap.autoconfigure.LdapAutoConfiguration.LdapAutoConfigurationRuntimeHints; 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.context.annotation.ImportRuntimeHints; import org.springframework.core.env.Environment; import org.springframework.ldap.convert.ConverterUtils; import org.springframework.ldap.core.ContextSource; @@ -41,6 +50,8 @@ import org.springframework.ldap.core.support.DirContextAuthenticationStrategy; import org.springframework.ldap.core.support.LdapContextSource; import org.springframework.ldap.odm.core.ObjectDirectoryMapper; import org.springframework.ldap.odm.core.impl.DefaultObjectDirectoryMapper; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * {@link EnableAutoConfiguration Auto-configuration} for LDAP. @@ -52,16 +63,16 @@ import org.springframework.ldap.odm.core.impl.DefaultObjectDirectoryMapper; @AutoConfiguration @ConditionalOnClass(ContextSource.class) @EnableConfigurationProperties(LdapProperties.class) +@ImportRuntimeHints(LdapAutoConfigurationRuntimeHints.class) public final class LdapAutoConfiguration { + private static final String SOCKET_FACTORY_ENV_KEY = "java.naming.ldap.factory.socket"; + @Bean @ConditionalOnMissingBean(LdapConnectionDetails.class) - PropertiesLdapConnectionDetails propertiesLdapConnectionDetails( - LdapProperties properties, - Environment environment, + PropertiesLdapConnectionDetails propertiesLdapConnectionDetails(LdapProperties properties, Environment environment, ObjectProvider sslBundles) { - return new PropertiesLdapConnectionDetails( - properties, environment, sslBundles.getIfAvailable()); + return new PropertiesLdapConnectionDetails(properties, environment, sslBundles.getIfAvailable()); } @Bean @@ -69,23 +80,7 @@ public final class LdapAutoConfiguration { LdapContextSource ldapContextSource(LdapConnectionDetails connectionDetails, LdapProperties properties, ObjectProvider dirContextAuthenticationStrategy) { LdapContextSource source = new LdapContextSource(); - 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 + dirContextAuthenticationStrategy.ifUnique(source::setAuthenticationStrategy); PropertyMapper propertyMapper = PropertyMapper.get(); propertyMapper.from(connectionDetails.getUsername()).to(source::setUserDn); propertyMapper.from(connectionDetails.getPassword()).to(source::setPassword); @@ -95,11 +90,46 @@ public final class LdapAutoConfiguration { .to(source::setReferral); propertyMapper.from(connectionDetails.getBase()).to(source::setBase); propertyMapper.from(connectionDetails.getUrls()).to(source::setUrls); - propertyMapper.from(properties.getBaseEnvironment()) - .to((baseEnvironment) -> source.setBaseEnvironmentProperties(Collections.unmodifiableMap(baseEnvironment))); + source.setBaseEnvironmentProperties(baseEnvironmentProperties(connectionDetails, properties)); return source; } + /** + * Returns the JNDI environment shared by the anonymous and the authenticated + * environment of the context source. The SSL bundle is applied here rather than + * through a {@link DirContextAuthenticationStrategy} as the strategy is not consulted + * when read-only operations use an anonymous environment. + *

+ * A socket factory in the base environment conflicts with the bundle and is rejected, + * but only when the bundle also came from the properties. A bundle from another + * {@link LdapConnectionDetails} bean takes precedence over the properties instead. + * @param connectionDetails the connection details + * @param properties the LDAP properties + * @return the base environment properties + */ + private Map baseEnvironmentProperties(LdapConnectionDetails connectionDetails, + LdapProperties properties) { + Map baseEnvironment = new LinkedHashMap<>(properties.getBaseEnvironment()); + SslBundle sslBundle = connectionDetails.getSslBundle(); + if (sslBundle != null) { + Assert.state(usesLdaps(connectionDetails.getUrls()), + "SSL bundle has been configured but not all LDAP URLs use the 'ldaps' scheme"); + if (connectionDetails instanceof PropertiesLdapConnectionDetails) { + Assert.state(!baseEnvironment.containsKey(SOCKET_FACTORY_ENV_KEY), + () -> "SSL bundle has been configured but '" + SOCKET_FACTORY_ENV_KEY + + "' has also been set in the base environment. Use either an SSL bundle or your own socket factory, not both"); + } + LdapSslSocketFactory.setSslBundle(sslBundle); + baseEnvironment.put(SOCKET_FACTORY_ENV_KEY, LdapSslSocketFactory.class.getName()); + } + return Collections.unmodifiableMap(baseEnvironment); + } + + private boolean usesLdaps(String[] urls) { + return !ObjectUtils.isEmpty(urls) + && Arrays.stream(urls).allMatch((url) -> url.toLowerCase(Locale.ROOT).startsWith("ldaps://")); + } + @Bean @ConditionalOnMissingBean ObjectDirectoryMapper objectDirectoryMapper() { @@ -126,4 +156,13 @@ public final class LdapAutoConfiguration { return ldapTemplate; } + static class LdapAutoConfigurationRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + hints.reflection().registerType(LdapSslSocketFactory.class, MemberCategory.INVOKE_PUBLIC_METHODS); + } + + } + } 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 2471533951b..d1dcf0e52f3 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 @@ -62,6 +62,7 @@ public interface LdapConnectionDetails extends ConnectionDetails { /** * SSL bundle to use to establish the LDAP connection. * @return the SSL bundle to use, or {@code null} if none + * @since 4.2.0 */ 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 01db1e745e0..c5d2e62df72 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 @@ -40,6 +40,8 @@ public class LdapProperties { private static final int DEFAULT_PORT = 389; + private static final int DEFAULT_SSL_PORT = 636; + /** * LDAP URLs of the server. */ @@ -143,18 +145,21 @@ public class LdapProperties { public String[] determineUrls(Environment environment) { if (ObjectUtils.isEmpty(this.urls)) { - return new String[] { "ldap://localhost:" + determinePort(environment) }; + boolean useSsl = this.ssl.isEnabled(); + String protocol = useSsl ? "ldaps" : "ldap"; + int defaultPort = useSsl ? DEFAULT_SSL_PORT : DEFAULT_PORT; + return new String[] { protocol + "://localhost:" + determinePort(environment, defaultPort) }; } return this.urls; } - private int determinePort(Environment environment) { + private int determinePort(Environment environment, int defaultPort) { Assert.notNull(environment, "'environment' must not be null"); String localPort = environment.getProperty("local.ldap.port"); if (localPort != null) { return Integer.parseInt(localPort); } - return DEFAULT_PORT; + return defaultPort; } /** @@ -211,11 +216,25 @@ public class LdapProperties { */ 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 : StringUtils.hasText(this.bundle); + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + public @Nullable String getBundle() { return this.bundle; } @@ -224,15 +243,6 @@ public class LdapProperties { 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); - } - } /** diff --git a/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapSslSocketFactory.java b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapSslSocketFactory.java new file mode 100644 index 00000000000..aa26868dbd1 --- /dev/null +++ b/module/spring-boot-ldap/src/main/java/org/springframework/boot/ldap/autoconfigure/LdapSslSocketFactory.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.ldap.autoconfigure; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Socket; + +import javax.net.SocketFactory; +import javax.net.ssl.SSLSocketFactory; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.util.Assert; + +/** + * Bridges the JNDI {@code java.naming.ldap.factory.socket} environment property to an + * {@link SslBundle}. That property only accepts a class name, which JNDI loads and on + * which it then calls {@link #getDefault()}, with no way of passing an actual factory + * instance. The bundle to use is therefore held in a static field. + *

+ * JNDI calls {@link #getDefault()} once per new connection and the {@link SslBundle} is + * asked for a fresh {@code SSLContext} each time, so key and trust material of a bundle + * that has been reloaded is picked up by connections opened from then on. Reloading is + * only tracked for a bundle configured by name through {@code spring.ldap.ssl.bundle}; a + * bundle supplied by a custom {@link LdapConnectionDetails} is used as given. + *

+ * As the bundle is held statically, a single {@link SslBundle} applies JVM-wide. This is + * sufficient for the auto-configured + * {@link org.springframework.ldap.core.support.LdapContextSource}, of which there is at + * most one per application context. + *

+ * This class is referenced by name from a JNDI environment and is not intended to be used + * directly. + * + * @author Moritz Halbritter + * @since 4.2.0 + */ +public final class LdapSslSocketFactory extends SSLSocketFactory { + + private static final Log logger = LogFactory.getLog(LdapSslSocketFactory.class); + + private static volatile @Nullable SslBundle sslBundle; + + private final SSLSocketFactory delegate; + + private LdapSslSocketFactory(SslBundle sslBundle) { + this.delegate = sslBundle.createSslContext().getSocketFactory(); + } + + public static SocketFactory getDefault() { + SslBundle sslBundle = LdapSslSocketFactory.sslBundle; + Assert.state(sslBundle != null, "No SSL bundle has been set"); + return new LdapSslSocketFactory(sslBundle); + } + + static void setSslBundle(@Nullable SslBundle sslBundle) { + SslBundle previous = LdapSslSocketFactory.sslBundle; + if (previous != null && sslBundle != null && previous != sslBundle) { + logger.warn("A different SSL bundle has already been set for LDAP. As the bundle applies JVM-wide, " + + "LDAPS connections opened from now on use the key and trust material of the new bundle, " + + "including connections from context sources that were configured with the previous one"); + } + LdapSslSocketFactory.sslBundle = sslBundle; + } + + /** + * Replaces the bundle with a reloaded version of itself. Unlike + * {@link #setSslBundle(SslBundle)} this does not warn, as the material is being + * reloaded for the same bundle rather than claimed by another context source. + * @param sslBundle the reloaded SSL bundle + */ + static void updateSslBundle(SslBundle sslBundle) { + LdapSslSocketFactory.sslBundle = sslBundle; + } + + @Override + public String[] getDefaultCipherSuites() { + return this.delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return this.delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException { + return this.delegate.createSocket(socket, host, port, autoClose); + } + + @Override + public Socket createSocket(Socket socket, InputStream consumed, boolean autoClose) throws IOException { + return this.delegate.createSocket(socket, consumed, autoClose); + } + + /** + * Creates an unconnected socket. JNDI tries this first when a connect timeout has + * been configured, so it has to be delegated rather than inheriting + * {@link javax.net.SocketFactory#createSocket()}, which throws and makes JNDI fall + * back to a connected socket that ignores the timeout. + * @return an unconnected socket + * @throws IOException if the socket cannot be created + */ + @Override + public Socket createSocket() throws IOException { + return this.delegate.createSocket(); + } + + @Override + public Socket createSocket(String host, int port) throws IOException { + return this.delegate.createSocket(host, port); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localAddress, int localPort) throws IOException { + return this.delegate.createSocket(host, port, localAddress, localPort); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return this.delegate.createSocket(host, port); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) + throws IOException { + return this.delegate.createSocket(address, port, localAddress, localPort); + } + +} 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 1ac4a6794c4..5b2ed84f12f 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 @@ -42,6 +42,20 @@ class PropertiesLdapConnectionDetails implements LdapConnectionDetails { this.properties = properties; this.environment = environment; this.sslBundles = sslBundles; + registerSslBundleUpdateHandler(); + } + + /** + * Keeps {@link LdapSslSocketFactory} up-to-date when the configured bundle is + * reloaded. Without this, the bundle resolved when the context source was created is + * held on to and reloaded key or trust material is never used. + */ + private void registerSslBundleUpdateHandler() { + LdapProperties.Ssl ssl = this.properties.getSsl(); + if (this.sslBundles == null || !ssl.isEnabled() || !StringUtils.hasLength(ssl.getBundle())) { + return; + } + this.sslBundles.addBundleUpdateHandler(ssl.getBundle(), LdapSslSocketFactory::updateSslBundle); } @Override @@ -67,14 +81,14 @@ class PropertiesLdapConnectionDetails implements LdapConnectionDetails { @Override public @Nullable SslBundle getSslBundle() { LdapProperties.Ssl ssl = this.properties.getSsl(); - if (!ssl.determineEnabled()) { + if (!ssl.isEnabled()) { 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; + return SslBundle.systemDefault(); } } 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 747d625977d..2aa0ed491a8 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 @@ -17,7 +17,6 @@ package org.springframework.boot.ldap.testcontainers; import org.jspecify.annotations.Nullable; - import org.testcontainers.ldap.LLdapContainer; import org.springframework.boot.ldap.autoconfigure.LdapConnectionDetails; 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 cc7dba53cee..d83905a956b 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 @@ -21,7 +21,6 @@ import java.util.Map; import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; - import org.testcontainers.containers.Container; import org.testcontainers.containers.GenericContainer; 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 65b376e5d26..eadcff8af1f 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 @@ -16,15 +16,26 @@ package org.springframework.boot.ldap.autoconfigure; +import java.util.Map; + import javax.naming.Name; import org.assertj.core.api.InstanceOfAssertFactories; -import org.junit.jupiter.api.Test; +import org.assertj.core.api.MapAssert; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.convert.ApplicationConversionService; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -36,13 +47,10 @@ import org.springframework.ldap.odm.core.ObjectDirectoryMapper; 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 org.springframework.test.util.ReflectionTestUtils; + import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; /** @@ -52,11 +60,19 @@ import static org.mockito.Mockito.mock; * @author Stephane Nicoll * @author Vedran Pavic */ +@ExtendWith(OutputCaptureExtension.class) class LdapAutoConfigurationTests { + private static final String SOCKET_FACTORY_ENV_KEY = "java.naming.ldap.factory.socket"; + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(LdapAutoConfiguration.class)); + @AfterEach + void clearSslBundle() { + LdapSslSocketFactory.setSslBundle(null); + } + @Test void contextSourceWithDefaultUrl() { this.contextRunner.run((context) -> { @@ -205,55 +221,192 @@ class LdapAutoConfigurationTests { 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 contextSourceWithoutSslBundleDoesNotConfigureAuthenticationStrategy() { - this.contextRunner.run((context) -> { + void contextSourceWithUserProvidedPooledContextSource() { + this.contextRunner.withUserConfiguration(PooledContextSourceConfig.class).run((context) -> { LdapContextSource contextSource = context.getBean(LdapContextSource.class); - assertThat(contextSource).extracting("authenticationStrategy") - .isInstanceOf(SimpleDirContextAuthenticationStrategy.class); + assertThat(contextSource.getUrls()).containsExactly("ldap://localhost:389"); + assertThat(contextSource.isAnonymousReadOnly()).isTrue(); }); } + @Test + void shouldUseSslSocketFactoryWhenSslBundleConfiguredWithLdapsUrl() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + given(sslBundles.getBundle("test")).willReturn(sslBundle); + this.contextRunner.withPropertyValues("spring.ldap.urls=ldaps://localhost:636", "spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> { + LdapContextSource contextSource = context.getBean(LdapContextSource.class); + assertThat(contextSource.isAnonymousReadOnly()).isTrue(); + assertThatAnonymousEnv(context).containsEntry(SOCKET_FACTORY_ENV_KEY, + LdapSslSocketFactory.class.getName()); + assertThat(ReflectionTestUtils.getField(LdapSslSocketFactory.class, "sslBundle")).isSameAs(sslBundle); + }); + } + + @Test + void shouldUseSslSocketFactoryWhenSslBundleConfiguredAndReadOnlyOperationsAreAuthenticated() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + given(sslBundles.getBundle("test")).willReturn(sslBundle); + this.contextRunner + .withPropertyValues("spring.ldap.urls=ldaps://localhost:636", "spring.ldap.ssl.bundle=test", + "spring.ldap.username=root", "spring.ldap.password=secret") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> { + LdapContextSource contextSource = context.getBean(LdapContextSource.class); + assertThat(contextSource.isAnonymousReadOnly()).isFalse(); + assertThatAuthenticatedEnv(contextSource).containsEntry(SOCKET_FACTORY_ENV_KEY, + LdapSslSocketFactory.class.getName()); + }); + } + + @Test + void shouldUseSslSocketFactoryWhenSslBundleConfiguredWithUppercaseLdapsUrl() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + given(sslBundles.getBundle("test")).willReturn(sslBundle); + this.contextRunner.withPropertyValues("spring.ldap.urls=LDAPS://localhost:636", "spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> assertThatAnonymousEnv(context).containsEntry(SOCKET_FACTORY_ENV_KEY, + LdapSslSocketFactory.class.getName())); + } + + @Test + void shouldKeepCustomBaseEnvironmentWhenSslBundleConfigured() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + given(sslBundles.getBundle("test")).willReturn(sslBundle); + this.contextRunner + .withPropertyValues("spring.ldap.urls=ldaps://localhost:636", "spring.ldap.ssl.bundle=test", + "spring.ldap.baseEnvironment.java.naming.security.authentication:DIGEST-MD5") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> assertThatAnonymousEnv(context) + .containsEntry(SOCKET_FACTORY_ENV_KEY, LdapSslSocketFactory.class.getName()) + .containsEntry("java.naming.security.authentication", "DIGEST-MD5")); + } + + @Test + void shouldFailWhenSslBundleConfiguredWithoutLdapsUrl() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + given(sslBundles.getBundle("test")).willReturn(sslBundle); + this.contextRunner.withPropertyValues("spring.ldap.urls=ldap://localhost:389", "spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure() + .hasRootCauseInstanceOf(IllegalStateException.class) + .hasRootCauseMessage("SSL bundle has been configured but not all LDAP URLs use the 'ldaps' scheme"); + }); + } + + @Test + void shouldFailWhenSslBundleConfiguredWithMixedSchemeUrls() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + given(sslBundles.getBundle("test")).willReturn(sslBundle); + this.contextRunner + .withPropertyValues("spring.ldap.urls=ldaps://localhost:636,ldap://mycompany:389", + "spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> assertThat(context).getFailure() + .hasRootCauseInstanceOf(IllegalStateException.class) + .hasRootCauseMessage("SSL bundle has been configured but not all LDAP URLs use the 'ldaps' scheme")); + } + + @Test + void shouldUseReloadedSslBundleWhenBundleIsUpdated(CapturedOutput output) { + SslBundle original = mock(SslBundle.class, "original"); + SslBundle reloaded = mock(SslBundle.class, "reloaded"); + DefaultSslBundleRegistry sslBundles = new DefaultSslBundleRegistry("test", original); + this.contextRunner.withPropertyValues("spring.ldap.urls=ldaps://localhost:636", "spring.ldap.ssl.bundle=test") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> { + assertThat(ReflectionTestUtils.getField(LdapSslSocketFactory.class, "sslBundle")).isSameAs(original); + sslBundles.updateBundle("test", reloaded); + assertThat(ReflectionTestUtils.getField(LdapSslSocketFactory.class, "sslBundle")).isSameAs(reloaded); + assertThat(output).doesNotContain("A different SSL bundle has already been set for LDAP") + .doesNotContain("doesn't support SSL reloading"); + }); + } + + @Test + void shouldNotRegisterSslBundleUpdateHandlerWhenSslDisabled() { + SslBundle sslBundle = mock(SslBundle.class); + DefaultSslBundleRegistry sslBundles = new DefaultSslBundleRegistry("test", sslBundle); + this.contextRunner + .withPropertyValues("spring.ldap.ssl.bundle=test", "spring.ldap.ssl.enabled=false", + "spring.ldap.urls=ldap://localhost:389") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> { + sslBundles.updateBundle("test", mock(SslBundle.class)); + assertThat(ReflectionTestUtils.getField(LdapSslSocketFactory.class, "sslBundle")).isNull(); + }); + } + + @Test + void shouldFailWhenSslBundleConfiguredAndSocketFactorySetInBaseEnvironment() { + SslBundle sslBundle = mock(SslBundle.class); + SslBundles sslBundles = mock(SslBundles.class); + given(sslBundles.getBundle("test")).willReturn(sslBundle); + this.contextRunner + .withPropertyValues("spring.ldap.urls=ldaps://localhost:636", "spring.ldap.ssl.bundle=test", + "spring.ldap.baseEnvironment." + SOCKET_FACTORY_ENV_KEY + "=com.example.MySocketFactory") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> assertThat(context).getFailure() + .hasRootCauseInstanceOf(IllegalStateException.class) + .rootCause() + .hasMessageContaining("Use either an SSL bundle or your own socket factory, not both")); + } + + @Test + void shouldOverrideSocketFactorySetInBaseEnvironmentWhenSslBundleComesFromConnectionDetails() { + this.contextRunner + .withPropertyValues( + "spring.ldap.baseEnvironment." + SOCKET_FACTORY_ENV_KEY + "=com.example.MySocketFactory") + .withUserConfiguration(SslBundleConnectionDetailsConfiguration.class) + .run((context) -> assertThatAnonymousEnv(context).containsEntry(SOCKET_FACTORY_ENV_KEY, + LdapSslSocketFactory.class.getName())); + } + + @Test + void shouldKeepSocketFactorySetInBaseEnvironmentWhenNoSslBundleConfigured() { + this.contextRunner + .withPropertyValues("spring.ldap.urls=ldaps://localhost:636", + "spring.ldap.baseEnvironment." + SOCKET_FACTORY_ENV_KEY + "=com.example.MySocketFactory") + .run((context) -> assertThatAnonymousEnv(context).containsEntry(SOCKET_FACTORY_ENV_KEY, + "com.example.MySocketFactory")); + } + + @Test + void shouldUseSslSocketFactoryWhenSslEnabledAndNoBundleConfigured() { + this.contextRunner.withPropertyValues("spring.ldap.urls=ldaps://localhost:636", "spring.ldap.ssl.enabled=true") + .run((context) -> { + assertThatAnonymousEnv(context).containsEntry(SOCKET_FACTORY_ENV_KEY, + LdapSslSocketFactory.class.getName()); + assertThat(context.getBean(PropertiesLdapConnectionDetails.class).getSslBundle()).isNotNull(); + }); + } + + @Test + void shouldNotUseSslSocketFactoryWhenSslNotConfigured() { + this.contextRunner.run((context) -> assertThatAnonymousEnv(context).doesNotContainKey(SOCKET_FACTORY_ENV_KEY)); + } + + @Test + void shouldNotUseSslSocketFactoryWhenSslDisabledButBundleConfigured() { + SslBundles sslBundles = mock(SslBundles.class); + this.contextRunner + .withPropertyValues("spring.ldap.ssl.bundle=test", "spring.ldap.ssl.enabled=false", + "spring.ldap.urls=ldap://localhost:389") + .withBean(SslBundles.class, () -> sslBundles) + .run((context) -> assertThatAnonymousEnv(context).doesNotContainKey(SOCKET_FACTORY_ENV_KEY)); + } + @Test void contextSourceWithCustomUniqueDirContextAuthenticationStrategy() { this.contextRunner.withUserConfiguration(CustomDirContextAuthenticationStrategy.class).run((context) -> { @@ -280,26 +433,36 @@ class LdapAutoConfigurationTests { }); } - @Test - void contextSourceWithSslBundleAndMultipleCustomStrategiesUsesDefault() { - SslBundle sslBundle = mock(SslBundle.class); - SslBundles sslBundles = mock(SslBundles.class); - when(sslBundles.getBundle("test")).thenReturn(sslBundle); + private MapAssert assertThatAnonymousEnv(ApplicationContext context) { + LdapContextSource contextSource = context.getBean(LdapContextSource.class); + return assertThat(contextSource).extracting("anonymousEnv", InstanceOfAssertFactories.MAP); + } + + private MapAssert assertThatAuthenticatedEnv(LdapContextSource contextSource) { + @Nullable Map env = ReflectionTestUtils.invokeMethod(contextSource, "getAuthenticatedEnv", "root", + "secret"); + return assertThat(env); + } + + @Configuration(proxyBeanMethods = false) + static class SslBundleConnectionDetailsConfiguration { + + @Bean + LdapConnectionDetails ldapConnectionDetails() { + return new LdapConnectionDetails() { + + @Override + public String[] getUrls() { + return new String[] { "ldaps://ldap.example.com" }; + } + + @Override + public SslBundle getSslBundle() { + return mock(SslBundle.class); + } + }; + } - 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) @@ -328,11 +491,6 @@ class LdapAutoConfigurationTests { public String getPassword() { return "ldap-password"; } - - @Override - public @Nullable SslBundle getSslBundle() { - return null; - } }; } diff --git a/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapPropertiesTests.java b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapPropertiesTests.java index af5202ea4ee..b0bf08fc963 100644 --- a/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapPropertiesTests.java +++ b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapPropertiesTests.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.ldap.autoconfigure.LdapProperties.Template; import org.springframework.ldap.core.LdapTemplate; +import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; @@ -42,4 +43,40 @@ class LdapPropertiesTests { templateProperties.isIgnoreSizeLimitExceededException()); } + @Test + void determineUrlsShouldDefaultToPlainLdapWhenSslNotEnabled() { + LdapProperties properties = new LdapProperties(); + assertThat(properties.determineUrls(new MockEnvironment())).containsExactly("ldap://localhost:389"); + } + + @Test + void determineUrlsShouldDefaultToLdapsWhenSslEnabled() { + LdapProperties properties = new LdapProperties(); + properties.getSsl().setEnabled(true); + assertThat(properties.determineUrls(new MockEnvironment())).containsExactly("ldaps://localhost:636"); + } + + @Test + void determineUrlsShouldDefaultToLdapsWhenSslBundleConfigured() { + LdapProperties properties = new LdapProperties(); + properties.getSsl().setBundle("example"); + assertThat(properties.determineUrls(new MockEnvironment())).containsExactly("ldaps://localhost:636"); + } + + @Test + void determineUrlsShouldPreferLocalPortOverDefaultSslPort() { + LdapProperties properties = new LdapProperties(); + properties.getSsl().setEnabled(true); + MockEnvironment environment = new MockEnvironment().withProperty("local.ldap.port", "1234"); + assertThat(properties.determineUrls(environment)).containsExactly("ldaps://localhost:1234"); + } + + @Test + void determineUrlsShouldUseConfiguredUrlsRegardlessOfSsl() { + LdapProperties properties = new LdapProperties(); + properties.setUrls(new String[] { "ldap://localhost:1234" }); + properties.getSsl().setEnabled(true); + assertThat(properties.determineUrls(new MockEnvironment())).containsExactly("ldap://localhost:1234"); + } + } diff --git a/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapSslSocketFactoryTests.java b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapSslSocketFactoryTests.java new file mode 100644 index 00000000000..2a488f6ae4e --- /dev/null +++ b/module/spring-boot-ldap/src/test/java/org/springframework/boot/ldap/autoconfigure/LdapSslSocketFactoryTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.ldap.autoconfigure; + +import java.io.InputStream; +import java.net.Socket; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link LdapSslSocketFactory}. + * + * @author Moritz Halbritter + */ +@ExtendWith(OutputCaptureExtension.class) +class LdapSslSocketFactoryTests { + + @AfterEach + void clearSslBundle() { + LdapSslSocketFactory.setSslBundle(null); + } + + @Test + void shouldFailWhenNoSslBundleHasBeenSet() { + assertThatIllegalStateException().isThrownBy(LdapSslSocketFactory::getDefault) + .withMessage("No SSL bundle has been set"); + } + + @Test + void shouldUseSocketFactoryFromSslBundle() { + SSLSocketFactory socketFactory = mock(SSLSocketFactory.class); + LdapSslSocketFactory.setSslBundle(sslBundle(socketFactory)); + assertThat(LdapSslSocketFactory.getDefault()).extracting("delegate").isSameAs(socketFactory); + } + + @Test + void shouldDelegateCreationOfUnconnectedSocket() throws Exception { + SSLSocketFactory socketFactory = mock(SSLSocketFactory.class); + Socket socket = mock(Socket.class); + given(socketFactory.createSocket()).willReturn(socket); + LdapSslSocketFactory.setSslBundle(sslBundle(socketFactory)); + assertThat(LdapSslSocketFactory.getDefault().createSocket()).isSameAs(socket); + } + + @Test + void shouldDelegateCreationOfSocketWithConsumedInput() throws Exception { + SSLSocketFactory socketFactory = mock(SSLSocketFactory.class); + Socket socket = mock(Socket.class); + Socket wrapped = mock(Socket.class); + InputStream consumed = InputStream.nullInputStream(); + given(socketFactory.createSocket(socket, consumed, true)).willReturn(wrapped); + LdapSslSocketFactory.setSslBundle(sslBundle(socketFactory)); + assertThat(((SSLSocketFactory) LdapSslSocketFactory.getDefault()).createSocket(socket, consumed, true)) + .isSameAs(wrapped); + } + + @Test + void shouldWarnWhenReplacingBundleWithADifferentOne(CapturedOutput output) { + LdapSslSocketFactory.setSslBundle(sslBundle(mock(SSLSocketFactory.class))); + LdapSslSocketFactory.setSslBundle(sslBundle(mock(SSLSocketFactory.class))); + assertThat(output).contains("A different SSL bundle has already been set for LDAP"); + } + + @Test + void shouldNotWarnWhenSettingTheSameBundleAgain(CapturedOutput output) { + SslBundle sslBundle = sslBundle(mock(SSLSocketFactory.class)); + LdapSslSocketFactory.setSslBundle(sslBundle); + LdapSslSocketFactory.setSslBundle(sslBundle); + assertThat(output).doesNotContain("A different SSL bundle has already been set for LDAP"); + } + + @Test + void shouldCreateSslContextForEachInvocationToPickUpReloadedMaterial() { + SslBundle sslBundle = sslBundle(mock(SSLSocketFactory.class)); + LdapSslSocketFactory.setSslBundle(sslBundle); + LdapSslSocketFactory.getDefault(); + LdapSslSocketFactory.getDefault(); + then(sslBundle).should(times(2)).createSslContext(); + } + + private SslBundle sslBundle(SSLSocketFactory socketFactory) { + SSLContext sslContext = mock(SSLContext.class); + given(sslContext.getSocketFactory()).willReturn(socketFactory); + SslBundle sslBundle = mock(SslBundle.class); + given(sslBundle.createSslContext()).willReturn(sslContext); + return sslBundle; + } + +}