ソースを参照

Add LDAP factory beans

Issue gh-10138
Eleftheria Stein 3 年 前
コミット
6b56071c08

+ 1 - 0
config/spring-security-config.gradle

@@ -73,6 +73,7 @@ dependencies {
 	testImplementation "org.apache.directory.server:apacheds-protocol-ldap"
 	testImplementation "org.apache.directory.server:apacheds-server-jndi"
 	testImplementation 'org.apache.directory.shared:shared-ldap'
+	testImplementation "com.unboundid:unboundid-ldapsdk"
 	testImplementation 'jakarta.persistence:jakarta.persistence-api'
 	testImplementation 'org.hibernate:hibernate-core-jakarta'
 	testImplementation 'org.hsqldb:hsqldb'

+ 185 - 0
config/src/integration-test/java/org/springframework/security/config/ldap/EmbeddedLdapServerContextSourceFactoryBeanITests.java

@@ -0,0 +1,185 @@
+/*
+ * Copyright 2002-2022 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.security.config.ldap;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.UnsatisfiedDependencyException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.ldap.core.support.LdapContextSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
+
+@ExtendWith(SpringTestContextExtension.class)
+public class EmbeddedLdapServerContextSourceFactoryBeanITests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	private MockMvc mockMvc;
+
+	@Test
+	public void contextSourceFactoryBeanWhenEmbeddedServerThenAuthenticates() throws Exception {
+		this.spring.register(FromEmbeddedLdapServerConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword"))
+				.andExpect(authenticated().withUsername("bob"));
+	}
+
+	@Test
+	public void contextSourceFactoryBeanWhenPortZeroThenAuthenticates() throws Exception {
+		this.spring.register(PortZeroConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword"))
+				.andExpect(authenticated().withUsername("bob"));
+	}
+
+	@Test
+	public void contextSourceFactoryBeanWhenCustomLdifAndRootThenAuthenticates() throws Exception {
+		this.spring.register(CustomLdifAndRootConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("pg").password("password")).andExpect(authenticated().withUsername("pg"));
+	}
+
+	@Test
+	public void contextSourceFactoryBeanWhenCustomManagerDnThenAuthenticates() throws Exception {
+		this.spring.register(CustomManagerDnConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword"))
+				.andExpect(authenticated().withUsername("bob"));
+	}
+
+	@Test
+	public void contextSourceFactoryBeanWhenManagerDnAndNoPasswordThenException() {
+		assertThatExceptionOfType(UnsatisfiedDependencyException.class)
+				.isThrownBy(() -> this.spring.register(CustomManagerDnNoPasswordConfig.class).autowire())
+				.withRootCauseInstanceOf(IllegalStateException.class)
+				.withMessageContaining("managerPassword is required if managerDn is supplied");
+	}
+
+	@EnableWebSecurity
+	static class FromEmbeddedLdapServerConfig {
+
+		@Bean
+		EmbeddedLdapServerContextSourceFactoryBean contextSourceFactoryBean() {
+			return EmbeddedLdapServerContextSourceFactoryBean.fromEmbeddedLdapServer();
+		}
+
+		@Bean
+		AuthenticationManager authenticationManager(LdapContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class PortZeroConfig {
+
+		@Bean
+		EmbeddedLdapServerContextSourceFactoryBean contextSourceFactoryBean() {
+			EmbeddedLdapServerContextSourceFactoryBean factoryBean = EmbeddedLdapServerContextSourceFactoryBean
+					.fromEmbeddedLdapServer();
+			factoryBean.setPort(0);
+			return factoryBean;
+		}
+
+		@Bean
+		AuthenticationManager authenticationManager(LdapContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomLdifAndRootConfig {
+
+		@Bean
+		EmbeddedLdapServerContextSourceFactoryBean contextSourceFactoryBean() {
+			EmbeddedLdapServerContextSourceFactoryBean factoryBean = EmbeddedLdapServerContextSourceFactoryBean
+					.fromEmbeddedLdapServer();
+			factoryBean.setLdif("classpath*:test-server2.xldif");
+			factoryBean.setRoot("dc=monkeymachine,dc=co,dc=uk");
+			return factoryBean;
+		}
+
+		@Bean
+		AuthenticationManager authenticationManager(LdapContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=gorillas");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomManagerDnConfig {
+
+		@Bean
+		EmbeddedLdapServerContextSourceFactoryBean contextSourceFactoryBean() {
+			EmbeddedLdapServerContextSourceFactoryBean factoryBean = EmbeddedLdapServerContextSourceFactoryBean
+					.fromEmbeddedLdapServer();
+			factoryBean.setManagerDn("uid=admin,ou=system");
+			factoryBean.setManagerPassword("secret");
+			return factoryBean;
+		}
+
+		@Bean
+		AuthenticationManager authenticationManager(LdapContextSource contextSource) {
+			LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
+					contextSource, NoOpPasswordEncoder.getInstance());
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomManagerDnNoPasswordConfig {
+
+		@Bean
+		EmbeddedLdapServerContextSourceFactoryBean contextSourceFactoryBean() {
+			EmbeddedLdapServerContextSourceFactoryBean factoryBean = EmbeddedLdapServerContextSourceFactoryBean
+					.fromEmbeddedLdapServer();
+			factoryBean.setManagerDn("uid=admin,ou=system");
+			return factoryBean;
+		}
+
+		@Bean
+		AuthenticationManager authenticationManager(LdapContextSource contextSource) {
+			LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
+					contextSource, NoOpPasswordEncoder.getInstance());
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+}

+ 241 - 0
config/src/integration-test/java/org/springframework/security/config/ldap/LdapBindAuthenticationManagerFactoryITests.java

@@ -0,0 +1,241 @@
+/*
+ * Copyright 2002-2022 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.security.config.ldap;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.ldap.core.DirContextAdapter;
+import org.springframework.ldap.core.DirContextOperations;
+import org.springframework.ldap.core.support.BaseLdapPathContextSource;
+import org.springframework.ldap.core.support.LdapContextSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
+import org.springframework.security.ldap.server.ApacheDSContainer;
+import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
+import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;
+import org.springframework.security.ldap.userdetails.UserDetailsContextMapper;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.mockito.Mockito.mock;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
+
+@ExtendWith(SpringTestContextExtension.class)
+public class LdapBindAuthenticationManagerFactoryITests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	private MockMvc mockMvc;
+
+	@Test
+	public void authenticationManagerFactoryWhenFromContextSourceThenAuthenticates() throws Exception {
+		this.spring.register(FromContextSourceConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword"))
+				.andExpect(authenticated().withUsername("bob"));
+	}
+
+	@Test
+	public void ldapAuthenticationProviderCustomLdapAuthoritiesPopulator() throws Exception {
+		CustomAuthoritiesPopulatorConfig.LAP = new DefaultLdapAuthoritiesPopulator(mock(LdapContextSource.class),
+				null) {
+			@Override
+			protected Set<GrantedAuthority> getAdditionalRoles(DirContextOperations user, String username) {
+				return new HashSet<>(AuthorityUtils.createAuthorityList("ROLE_EXTRA"));
+			}
+		};
+
+		this.spring.register(CustomAuthoritiesPopulatorConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword")).andExpect(
+				authenticated().withAuthorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_EXTRA"))));
+	}
+
+	@Test
+	public void authenticationManagerFactoryWhenCustomAuthoritiesMapperThenUsed() throws Exception {
+		CustomAuthoritiesMapperConfig.AUTHORITIES_MAPPER = ((authorities) -> AuthorityUtils
+				.createAuthorityList("ROLE_CUSTOM"));
+
+		this.spring.register(CustomAuthoritiesMapperConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword")).andExpect(
+				authenticated().withAuthorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_CUSTOM"))));
+	}
+
+	@Test
+	public void authenticationManagerFactoryWhenCustomUserDetailsContextMapperThenUsed() throws Exception {
+		CustomUserDetailsContextMapperConfig.CONTEXT_MAPPER = new UserDetailsContextMapper() {
+			@Override
+			public UserDetails mapUserFromContext(DirContextOperations ctx, String username,
+					Collection<? extends GrantedAuthority> authorities) {
+				return User.withUsername("other").password("password").roles("USER").build();
+			}
+
+			@Override
+			public void mapUserToContext(UserDetails user, DirContextAdapter ctx) {
+			}
+		};
+
+		this.spring.register(CustomUserDetailsContextMapperConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword"))
+				.andExpect(authenticated().withUsername("other"));
+	}
+
+	@Test
+	public void authenticationManagerFactoryWhenCustomUserDnPatternsThenUsed() throws Exception {
+		this.spring.register(CustomUserDnPatternsConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword"))
+				.andExpect(authenticated().withUsername("bob"));
+	}
+
+	@Test
+	public void authenticationManagerFactoryWhenCustomUserSearchThenUsed() throws Exception {
+		this.spring.register(CustomUserSearchConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bobspassword"))
+				.andExpect(authenticated().withUsername("bob"));
+	}
+
+	@EnableWebSecurity
+	static class FromContextSourceConfig extends BaseLdapServerConfig {
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomAuthoritiesMapperConfig extends BaseLdapServerConfig {
+
+		static GrantedAuthoritiesMapper AUTHORITIES_MAPPER;
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=people");
+			factory.setAuthoritiesMapper(AUTHORITIES_MAPPER);
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomAuthoritiesPopulatorConfig extends BaseLdapServerConfig {
+
+		static LdapAuthoritiesPopulator LAP;
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=people");
+			factory.setLdapAuthoritiesPopulator(LAP);
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomUserDetailsContextMapperConfig extends BaseLdapServerConfig {
+
+		static UserDetailsContextMapper CONTEXT_MAPPER;
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=people");
+			factory.setUserDetailsContextMapper(CONTEXT_MAPPER);
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomUserDnPatternsConfig extends BaseLdapServerConfig {
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomUserSearchConfig extends BaseLdapServerConfig {
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
+			factory.setUserSearchFilter("uid={0}");
+			factory.setUserSearchBase("ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	abstract static class BaseLdapServerConfig implements DisposableBean {
+
+		private ApacheDSContainer container;
+
+		@Bean
+		ApacheDSContainer ldapServer() throws Exception {
+			this.container = new ApacheDSContainer("dc=springframework,dc=org", "classpath:/test-server.ldif");
+			this.container.setPort(0);
+			return this.container;
+		}
+
+		@Bean
+		BaseLdapPathContextSource contextSource(ApacheDSContainer container) {
+			int port = container.getLocalPort();
+			return new DefaultSpringSecurityContextSource("ldap://localhost:" + port + "/dc=springframework,dc=org");
+		}
+
+		@Override
+		public void destroy() {
+			this.container.stop();
+		}
+
+	}
+
+}

+ 114 - 0
config/src/integration-test/java/org/springframework/security/config/ldap/LdapPasswordComparisonAuthenticationManagerFactoryITests.java

@@ -0,0 +1,114 @@
+/*
+ * Copyright 2002-2022 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.security.config.ldap;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.ldap.core.support.BaseLdapPathContextSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
+import org.springframework.security.ldap.server.ApacheDSContainer;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
+
+@ExtendWith(SpringTestContextExtension.class)
+public class LdapPasswordComparisonAuthenticationManagerFactoryITests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	private MockMvc mockMvc;
+
+	@Test
+	public void authenticationManagerFactoryWhenCustomPasswordEncoderThenUsed() throws Exception {
+		this.spring.register(CustomPasswordEncoderConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bcrypt").password("password"))
+				.andExpect(authenticated().withUsername("bcrypt"));
+	}
+
+	@Test
+	public void authenticationManagerFactoryWhenCustomPasswordAttributeThenUsed() throws Exception {
+		this.spring.register(CustomPasswordAttributeConfig.class).autowire();
+
+		this.mockMvc.perform(formLogin().user("bob").password("bob")).andExpect(authenticated().withUsername("bob"));
+	}
+
+	@EnableWebSecurity
+	static class CustomPasswordEncoderConfig extends BaseLdapServerConfig {
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
+					contextSource, new BCryptPasswordEncoder());
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	static class CustomPasswordAttributeConfig extends BaseLdapServerConfig {
+
+		@Bean
+		AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
+			LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
+					contextSource, NoOpPasswordEncoder.getInstance());
+			factory.setPasswordAttribute("uid");
+			factory.setUserDnPatterns("uid={0},ou=people");
+			return factory.createAuthenticationManager();
+		}
+
+	}
+
+	@EnableWebSecurity
+	abstract static class BaseLdapServerConfig implements DisposableBean {
+
+		private ApacheDSContainer container;
+
+		@Bean
+		ApacheDSContainer ldapServer() throws Exception {
+			this.container = new ApacheDSContainer("dc=springframework,dc=org", "classpath:/test-server.ldif");
+			this.container.setPort(0);
+			return this.container;
+		}
+
+		@Bean
+		BaseLdapPathContextSource contextSource(ApacheDSContainer container) {
+			int port = container.getLocalPort();
+			return new DefaultSpringSecurityContextSource("ldap://localhost:" + port + "/dc=springframework,dc=org");
+		}
+
+		@Override
+		public void destroy() {
+			this.container.stop();
+		}
+
+	}
+
+}

+ 183 - 0
config/src/main/java/org/springframework/security/config/ldap/AbstractLdapAuthenticationManagerFactory.java

@@ -0,0 +1,183 @@
+/*
+ * Copyright 2002-2022 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.security.config.ldap;
+
+import org.springframework.ldap.core.support.BaseLdapPathContextSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.ProviderManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.ldap.authentication.AbstractLdapAuthenticator;
+import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
+import org.springframework.security.ldap.search.FilterBasedLdapUserSearch;
+import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;
+import org.springframework.security.ldap.userdetails.UserDetailsContextMapper;
+
+/**
+ * Creates an {@link AuthenticationManager} that can perform LDAP authentication.
+ *
+ * @author Eleftheria Stein
+ * @since 5.7
+ */
+public abstract class AbstractLdapAuthenticationManagerFactory<T extends AbstractLdapAuthenticator> {
+
+	AbstractLdapAuthenticationManagerFactory(BaseLdapPathContextSource contextSource) {
+		this.contextSource = contextSource;
+	}
+
+	private BaseLdapPathContextSource contextSource;
+
+	private String[] userDnPatterns;
+
+	private LdapAuthoritiesPopulator ldapAuthoritiesPopulator;
+
+	private GrantedAuthoritiesMapper authoritiesMapper;
+
+	private UserDetailsContextMapper userDetailsContextMapper;
+
+	private String userSearchFilter;
+
+	private String userSearchBase = "";
+
+	/**
+	 * Sets the {@link BaseLdapPathContextSource} used to perform LDAP authentication.
+	 * @param contextSource the {@link BaseLdapPathContextSource} used to perform LDAP
+	 * authentication
+	 */
+	public void setContextSource(BaseLdapPathContextSource contextSource) {
+		this.contextSource = contextSource;
+	}
+
+	/**
+	 * Gets the {@link BaseLdapPathContextSource} used to perform LDAP authentication.
+	 * @return the {@link BaseLdapPathContextSource} used to perform LDAP authentication
+	 */
+	protected final BaseLdapPathContextSource getContextSource() {
+		return this.contextSource;
+	}
+
+	/**
+	 * Sets the {@link LdapAuthoritiesPopulator} used to obtain a list of granted
+	 * authorities for an LDAP user.
+	 * @param ldapAuthoritiesPopulator the {@link LdapAuthoritiesPopulator} to use
+	 */
+	public void setLdapAuthoritiesPopulator(LdapAuthoritiesPopulator ldapAuthoritiesPopulator) {
+		this.ldapAuthoritiesPopulator = ldapAuthoritiesPopulator;
+	}
+
+	/**
+	 * Sets the {@link GrantedAuthoritiesMapper} used for converting the authorities
+	 * loaded from storage to a new set of authorities which will be associated to the
+	 * {@link UsernamePasswordAuthenticationToken}.
+	 * @param authoritiesMapper the {@link GrantedAuthoritiesMapper} used for mapping the
+	 * user's authorities
+	 */
+	public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) {
+		this.authoritiesMapper = authoritiesMapper;
+	}
+
+	/**
+	 * Sets a custom strategy to be used for creating the {@link UserDetails} which will
+	 * be stored as the principal in the {@link Authentication}.
+	 * @param userDetailsContextMapper the strategy instance
+	 */
+	public void setUserDetailsContextMapper(UserDetailsContextMapper userDetailsContextMapper) {
+		this.userDetailsContextMapper = userDetailsContextMapper;
+	}
+
+	/**
+	 * If your users are at a fixed location in the directory (i.e. you can work out the
+	 * DN directly from the username without doing a directory search), you can use this
+	 * attribute to map directly to the DN. It maps directly to the userDnPatterns
+	 * property of AbstractLdapAuthenticator. The value is a specific pattern used to
+	 * build the user's DN, for example "uid={0},ou=people". The key "{0}" must be present
+	 * and will be substituted with the username.
+	 * @param userDnPatterns the LDAP patterns for finding the usernames
+	 */
+	public void setUserDnPatterns(String... userDnPatterns) {
+		this.userDnPatterns = userDnPatterns;
+	}
+
+	/**
+	 * The LDAP filter used to search for users (optional). For example "(uid={0})". The
+	 * substituted parameter is the user's login name.
+	 * @param userSearchFilter the LDAP filter used to search for users
+	 */
+	public void setUserSearchFilter(String userSearchFilter) {
+		this.userSearchFilter = userSearchFilter;
+	}
+
+	/**
+	 * Search base for user searches. Defaults to "". Only used with
+	 * {@link #setUserSearchFilter(String)}.
+	 * @param userSearchBase search base for user searches
+	 */
+	public void setUserSearchBase(String userSearchBase) {
+		this.userSearchBase = userSearchBase;
+	}
+
+	/**
+	 * Returns the configured {@link AuthenticationManager} that can be used to perform
+	 * LDAP authentication.
+	 * @return the configured {@link AuthenticationManager}
+	 */
+	public final AuthenticationManager createAuthenticationManager() {
+		LdapAuthenticationProvider ldapAuthenticationProvider = getProvider();
+		return new ProviderManager(ldapAuthenticationProvider);
+	}
+
+	private LdapAuthenticationProvider getProvider() {
+		AbstractLdapAuthenticator authenticator = getAuthenticator();
+		LdapAuthenticationProvider provider;
+		if (this.ldapAuthoritiesPopulator != null) {
+			provider = new LdapAuthenticationProvider(authenticator, this.ldapAuthoritiesPopulator);
+		}
+		else {
+			provider = new LdapAuthenticationProvider(authenticator);
+		}
+		if (this.authoritiesMapper != null) {
+			provider.setAuthoritiesMapper(this.authoritiesMapper);
+		}
+		if (this.userDetailsContextMapper != null) {
+			provider.setUserDetailsContextMapper(this.userDetailsContextMapper);
+		}
+		return provider;
+	}
+
+	private AbstractLdapAuthenticator getAuthenticator() {
+		AbstractLdapAuthenticator authenticator = createDefaultLdapAuthenticator();
+		if (this.userSearchFilter != null) {
+			authenticator.setUserSearch(
+					new FilterBasedLdapUserSearch(this.userSearchBase, this.userSearchFilter, this.contextSource));
+		}
+		if (this.userDnPatterns != null && this.userDnPatterns.length > 0) {
+			authenticator.setUserDnPatterns(this.userDnPatterns);
+		}
+		authenticator.afterPropertiesSet();
+		return authenticator;
+	}
+
+	/**
+	 * Allows subclasses to supply the default {@link AbstractLdapAuthenticator}.
+	 * @return the {@link AbstractLdapAuthenticator} that will be configured for LDAP
+	 * authentication
+	 */
+	protected abstract T createDefaultLdapAuthenticator();
+
+}

+ 185 - 0
config/src/main/java/org/springframework/security/config/ldap/EmbeddedLdapServerContextSourceFactoryBean.java

@@ -0,0 +1,185 @@
+/*
+ * Copyright 2002-2022 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.security.config.ldap;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+import org.springframework.context.Lifecycle;
+import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
+import org.springframework.security.ldap.server.EmbeddedLdapServerContainer;
+import org.springframework.security.ldap.server.UnboundIdContainer;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Creates a {@link DefaultSpringSecurityContextSource} used to perform LDAP
+ * authentication and starts and in-memory LDAP server.
+ *
+ * @author Eleftheria Stein
+ * @since 5.7
+ */
+public class EmbeddedLdapServerContextSourceFactoryBean
+		implements FactoryBean<DefaultSpringSecurityContextSource>, DisposableBean, ApplicationContextAware {
+
+	private static final String UNBOUNDID_CLASSNAME = "com.unboundid.ldap.listener.InMemoryDirectoryServer";
+
+	private static final int DEFAULT_PORT = 33389;
+
+	private static final int RANDOM_PORT = 0;
+
+	private Integer port;
+
+	private String ldif = "classpath*:*.ldif";
+
+	private String root = "dc=springframework,dc=org";
+
+	private ApplicationContext context;
+
+	private String managerDn;
+
+	private String managerPassword;
+
+	private EmbeddedLdapServerContainer container;
+
+	/**
+	 * Create an EmbeddedLdapServerContextSourceFactoryBean that will use an embedded LDAP
+	 * server to perform LDAP authentication. This requires a dependency on
+	 * `com.unboundid:unboundid-ldapsdk`.
+	 * @return the EmbeddedLdapServerContextSourceFactoryBean
+	 */
+	public static EmbeddedLdapServerContextSourceFactoryBean fromEmbeddedLdapServer() {
+		return new EmbeddedLdapServerContextSourceFactoryBean();
+	}
+
+	/**
+	 * Specifies an LDIF to load at startup for an embedded LDAP server. The default is
+	 * "classpath*:*.ldif".
+	 * @param ldif the ldif to load at startup for an embedded LDAP server.
+	 */
+	public void setLdif(String ldif) {
+		this.ldif = ldif;
+	}
+
+	/**
+	 * The port to connect to LDAP to (the default is 33389 or random available port if
+	 * unavailable). Supplying 0 as the port indicates that a random available port should
+	 * be selected.
+	 * @param port the port to connect to
+	 */
+	public void setPort(int port) {
+		this.port = port;
+	}
+
+	/**
+	 * Optional root suffix for the embedded LDAP server. Default is
+	 * "dc=springframework,dc=org".
+	 * @param root root suffix for the embedded LDAP server
+	 */
+	public void setRoot(String root) {
+		this.root = root;
+	}
+
+	/**
+	 * Username (DN) of the "manager" user identity (i.e. "uid=admin,ou=system") which
+	 * will be used to authenticate to an LDAP server. If omitted, anonymous access will
+	 * be used.
+	 * @param managerDn the username (DN) of the "manager" user identity used to
+	 * authenticate to a LDAP server.
+	 */
+	public void setManagerDn(String managerDn) {
+		this.managerDn = managerDn;
+	}
+
+	/**
+	 * The password for the manager DN. This is required if the
+	 * {@link #setManagerDn(String)} is specified.
+	 * @param managerPassword password for the manager DN
+	 */
+	public void setManagerPassword(String managerPassword) {
+		this.managerPassword = managerPassword;
+	}
+
+	@Override
+	public DefaultSpringSecurityContextSource getObject() throws Exception {
+		if (!ClassUtils.isPresent(UNBOUNDID_CLASSNAME, getClass().getClassLoader())) {
+			throw new IllegalStateException("Embedded LDAP server is not provided");
+		}
+		this.container = getContainer();
+		this.port = this.container.getPort();
+		DefaultSpringSecurityContextSource contextSourceFromProviderUrl = new DefaultSpringSecurityContextSource(
+				"ldap://127.0.0.1:" + this.port + "/" + this.root);
+		if (this.managerDn != null) {
+			contextSourceFromProviderUrl.setUserDn(this.managerDn);
+			if (this.managerPassword == null) {
+				throw new IllegalStateException("managerPassword is required if managerDn is supplied");
+			}
+			contextSourceFromProviderUrl.setPassword(this.managerPassword);
+		}
+		contextSourceFromProviderUrl.afterPropertiesSet();
+		return contextSourceFromProviderUrl;
+	}
+
+	@Override
+	public Class<?> getObjectType() {
+		return DefaultSpringSecurityContextSource.class;
+	}
+
+	@Override
+	public void destroy() {
+		if (this.container instanceof Lifecycle) {
+			((Lifecycle) this.container).stop();
+		}
+	}
+
+	@Override
+	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+		this.context = applicationContext;
+	}
+
+	private EmbeddedLdapServerContainer getContainer() {
+		if (!ClassUtils.isPresent(UNBOUNDID_CLASSNAME, getClass().getClassLoader())) {
+			throw new IllegalStateException("Embedded LDAP server is not provided");
+		}
+		UnboundIdContainer unboundIdContainer = new UnboundIdContainer(this.root, this.ldif);
+		unboundIdContainer.setApplicationContext(this.context);
+		unboundIdContainer.setPort(getEmbeddedServerPort());
+		unboundIdContainer.afterPropertiesSet();
+		return unboundIdContainer;
+	}
+
+	private int getEmbeddedServerPort() {
+		if (this.port == null) {
+			this.port = getDefaultEmbeddedServerPort();
+		}
+		return this.port;
+	}
+
+	private int getDefaultEmbeddedServerPort() {
+		try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
+			return serverSocket.getLocalPort();
+		}
+		catch (IOException ex) {
+			return RANDOM_PORT;
+		}
+	}
+
+}

+ 41 - 0
config/src/main/java/org/springframework/security/config/ldap/LdapBindAuthenticationManagerFactory.java

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2002-2022 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.security.config.ldap;
+
+import org.springframework.ldap.core.support.BaseLdapPathContextSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.ldap.authentication.BindAuthenticator;
+
+/**
+ * Creates an {@link AuthenticationManager} that can perform LDAP authentication using
+ * bind authentication.
+ *
+ * @author Eleftheria Stein
+ * @since 5.7
+ */
+public class LdapBindAuthenticationManagerFactory extends AbstractLdapAuthenticationManagerFactory<BindAuthenticator> {
+
+	public LdapBindAuthenticationManagerFactory(BaseLdapPathContextSource contextSource) {
+		super(contextSource);
+	}
+
+	@Override
+	protected BindAuthenticator createDefaultLdapAuthenticator() {
+		return new BindAuthenticator(getContextSource());
+	}
+
+}

+ 75 - 0
config/src/main/java/org/springframework/security/config/ldap/LdapPasswordComparisonAuthenticationManagerFactory.java

@@ -0,0 +1,75 @@
+/*
+ * Copyright 2002-2022 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.security.config.ldap;
+
+import org.springframework.ldap.core.support.BaseLdapPathContextSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.ldap.authentication.PasswordComparisonAuthenticator;
+import org.springframework.util.Assert;
+
+/**
+ * Creates an {@link AuthenticationManager} that can perform LDAP authentication using
+ * password comparison.
+ *
+ * @author Eleftheria Stein
+ * @since 5.7
+ */
+public class LdapPasswordComparisonAuthenticationManagerFactory
+		extends AbstractLdapAuthenticationManagerFactory<PasswordComparisonAuthenticator> {
+
+	private PasswordEncoder passwordEncoder;
+
+	private String passwordAttribute;
+
+	public LdapPasswordComparisonAuthenticationManagerFactory(BaseLdapPathContextSource contextSource,
+			PasswordEncoder passwordEncoder) {
+		super(contextSource);
+		setPasswordEncoder(passwordEncoder);
+	}
+
+	/**
+	 * Specifies the {@link PasswordEncoder} to be used when authenticating with password
+	 * comparison.
+	 * @param passwordEncoder the {@link PasswordEncoder} to use
+	 */
+	public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
+		Assert.notNull(passwordEncoder, "passwordEncoder must not be null.");
+		this.passwordEncoder = passwordEncoder;
+	}
+
+	/**
+	 * The attribute in the directory which contains the user password. Only used when
+	 * authenticating with password comparison. Defaults to "userPassword".
+	 * @param passwordAttribute the attribute in the directory which contains the user
+	 * password
+	 */
+	public void setPasswordAttribute(String passwordAttribute) {
+		this.passwordAttribute = passwordAttribute;
+	}
+
+	@Override
+	protected PasswordComparisonAuthenticator createDefaultLdapAuthenticator() {
+		PasswordComparisonAuthenticator ldapAuthenticator = new PasswordComparisonAuthenticator(getContextSource());
+		if (this.passwordAttribute != null) {
+			ldapAuthenticator.setPasswordAttributeName(this.passwordAttribute);
+		}
+		ldapAuthenticator.setPasswordEncoder(this.passwordEncoder);
+		return ldapAuthenticator;
+	}
+
+}

+ 10 - 0
itest/ldap/embedded-ldap-mode-unboundid/src/integration-test/resources/users.ldif

@@ -38,6 +38,16 @@ sn: Wombat
 uid: scott
 userPassword: wombat
 
+dn: uid=bcrypt,ou=people,dc=springframework,dc=org
+objectclass: top
+objectclass: person
+objectclass: organizationalPerson
+objectclass: inetOrgPerson
+cn: BCrypt user
+sn: BCrypt
+uid: bcrypt
+userPassword: $2a$10$FBAKClV1zBIOOC9XMXf3AO8RoGXYVYsfvUdoLxGkd/BnXEn4tqT3u
+
 dn: cn=user,ou=groups,dc=springframework,dc=org
 objectclass: top
 objectclass: groupOfNames