Polish "Add SSL bundle support to LDAP auto-configuration"

Configure the SSL bundle through the context source's base environment
rather than a DirContextAuthenticationStrategy. The strategy is not
consulted when read-only operations use an anonymous environment,
which is the default when no username is set, so LDAPS connections
silently used the JVM's default trust material. It was also bypassed
entirely when a custom strategy bean was defined.
Register an SSL bundle update handler so that reloaded key and trust
material is used by subsequent connections.

Target LDAPS rather than StartTLS by setting the JNDI
java.naming.ldap.factory.socket property, and fail at startup if a
bundle is combined with a non-ldaps URL or with a socket factory that
has also been set through spring.ldap.base-environment. Delegate all
socket factory methods so that a configured connect timeout is
applied.

Add spring.ldap.ssl.enabled to use the platform's default trust and
key material without a bundle, default the URL to
ldaps://localhost:636 when SSL is enabled, and register the reflection
hint that JNDI needs to load the socket factory by name in a native
image. Cover the result with integration tests that search over LDAPS
against OpenLDAP and LLDAP containers, including certificates that are
untrusted or issued to a different host.

See gh-51382
This commit is contained in:
Moritz Halbritter
2026-08-26 10:35:57 +02:00
parent 255677d8e7
commit 969f1c03bf
19 changed files with 1084 additions and 116 deletions
@@ -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,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> 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> 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.
* <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() {
@@ -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);
}
}
}
@@ -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;
@@ -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);
}
}
/**
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
@@ -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;
@@ -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<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);
}
};
}
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;
}
};
}
@@ -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;
}
}