Merge pull request #51382 from dhruv9b

Closes gh-51382

* pr/51382:
  Polish "Add SSL bundle support to LDAP auto-configuration"
  Add SSL bundle support to LDAP auto-configuration
This commit is contained in:
Moritz Halbritter
2026-08-26 10:58:49 +02:00
19 changed files with 1153 additions and 9 deletions
@@ -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
@@ -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<String> dc = this.ldapTemplate.search(LdapQueryBuilder.query().where("objectclass").is("dcObject"),
(AttributesMapper<String>) (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<String>) (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<String>) (attributes) -> attributes.get("dc").get().toString()))
.withRootCauseInstanceOf(CertificateException.class)
.havingRootCause()
.withMessageContaining(openLdap.getHost());
}
@Configuration(proxyBeanMethods = false)
@ImportAutoConfiguration(LdapAutoConfiguration.class)
static class TestConfiguration {
}
}
}
@@ -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<String> cn = this.ldapTemplate.search(LdapQueryBuilder.query().where("objectClass").is("inetOrgPerson"),
(AttributesMapper<String>) (attributes) -> attributes.get("cn").get().toString());
assertThat(cn).singleElement().isEqualTo("Administrator");
}
@Configuration(proxyBeanMethods = false)
@ImportAutoConfiguration(LdapAutoConfiguration.class)
static class TestConfiguration {
}
}
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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,8 +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.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;
@@ -38,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.
@@ -49,13 +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) {
return new PropertiesLdapConnectionDetails(properties, environment);
PropertiesLdapConnectionDetails propertiesLdapConnectionDetails(LdapProperties properties, Environment environment,
ObjectProvider<SslBundles> sslBundles) {
return new PropertiesLdapConnectionDetails(properties, environment, sslBundles.getIfAvailable());
}
@Bean
@@ -73,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.
* <p>
* 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<String, Object> baseEnvironmentProperties(LdapConnectionDetails connectionDetails,
LdapProperties properties) {
Map<String, Object> 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() {
@@ -104,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);
}
}
}
@@ -19,6 +19,7 @@ package org.springframework.boot.ldap.autoconfigure;
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,13 @@ 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
* @since 4.2.0
*/
default @Nullable SslBundle getSslBundle() {
return null;
}
}
@@ -27,6 +27,7 @@ import org.springframework.ldap.ReferralException;
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.
@@ -39,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.
*/
@@ -78,6 +81,8 @@ public class LdapProperties {
private final Template template = new Template();
private final Ssl ssl = new Ssl();
public String @Nullable [] getUrls() {
return this.urls;
}
@@ -134,20 +139,27 @@ public class LdapProperties {
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) };
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;
}
/**
@@ -199,6 +211,40 @@ public class LdapProperties {
}
/**
* SSL configuration.
*/
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;
}
public void setBundle(@Nullable String bundle) {
this.bundle = bundle;
}
}
/**
* Define the methods to handle referrals.
*/
@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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);
}
}
@@ -18,7 +18,11 @@ package org.springframework.boot.ldap.autoconfigure;
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,27 @@ 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;
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
@@ -56,4 +78,17 @@ class PropertiesLdapConnectionDetails implements LdapConnectionDetails {
return this.properties.getPassword();
}
@Override
public @Nullable SslBundle getSslBundle() {
LdapProperties.Ssl ssl = this.properties.getSsl();
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 SslBundle.systemDefault();
}
}
@@ -16,9 +16,11 @@
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 +66,11 @@ class LLdapContainerConnectionDetailsFactory
return getContainer().getPassword();
}
@Override
public @Nullable SslBundle getSslBundle() {
return super.getSslBundle();
}
}
}
@@ -20,10 +20,12 @@ import java.util.Arrays;
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 +86,11 @@ class OpenLdapContainerConnectionDetailsFactory
return getContainer().getEnvMap().getOrDefault("LDAP_ADMIN_PASSWORD", "admin");
}
@Override
public @Nullable SslBundle getSslBundle() {
return super.getSslBundle();
}
}
}
@@ -16,14 +16,26 @@
package org.springframework.boot.ldap.autoconfigure;
import java.util.Map;
import javax.naming.Name;
import org.assertj.core.api.InstanceOfAssertFactories;
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;
@@ -35,8 +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.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
@@ -46,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) -> {
@@ -209,6 +231,182 @@ class LdapAutoConfigurationTests {
});
}
@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) -> {
@@ -235,6 +433,38 @@ class LdapAutoConfigurationTests {
});
}
private MapAssert<Object, Object> assertThatAnonymousEnv(ApplicationContext context) {
LdapContextSource contextSource = context.getBean(LdapContextSource.class);
return assertThat(contextSource).extracting("anonymousEnv", InstanceOfAssertFactories.MAP);
}
private MapAssert<Object, Object> assertThatAuthenticatedEnv(LdapContextSource contextSource) {
@Nullable Map<Object, Object> 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);
}
};
}
}
@Configuration(proxyBeanMethods = false)
static class ConnectionDetailsConfiguration {
@@ -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");
}
}
@@ -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;
}
}