From 238227364a6a6de292ba22b7f4adcc346ac67ef6 Mon Sep 17 00:00:00 2001 From: Siva Sai Udayagiri Date: Mon, 6 Oct 2025 16:44:36 -0400 Subject: [PATCH] Honor @Primary for UserDetailsService and UserDetailsPasswordService in InitializeUserDetailsBeanManagerConfigurer Signed-off-by: Siva Sai Udayagiri --- ...alizeUserDetailsBeanManagerConfigurer.java | 80 ++++++--- ...UserDetailsBeanManagerConfigurerTests.java | 153 ++++++++++++++++++ 2 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 config/src/test/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurerTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java index 6f3d714144..0509fefadd 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java @@ -21,6 +21,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -33,9 +34,12 @@ import org.springframework.security.crypto.password.PasswordEncoder; /** - * Lazily initializes the global authentication with a {@link UserDetailsService} if it is - * not yet configured and there is only a single Bean of that type. Optionally, if a - * {@link PasswordEncoder} is defined will wire this up too. + * Lazily initializes the global authentication with a {@link UserDetailsService}. If + * multiple beans of that type exist, the container's autowire rules are used to select a + * single candidate (e.g. {@code @Primary}). If no single candidate can be resolved, the + * configurer logs a warning and does not auto-wire. Optionally wires a + * {@link PasswordEncoder}, {@link UserDetailsPasswordService}, and + * {@link CompromisedPasswordChecker} when available. * * @author Rob Winch * @author Ngoc Nhan @@ -48,9 +52,6 @@ class InitializeUserDetailsBeanManagerConfigurer extends GlobalAuthenticationCon private final ApplicationContext context; - /** - * @param context - */ InitializeUserDetailsBeanManagerConfigurer(ApplicationContext context) { this.context = context; } @@ -68,6 +69,7 @@ class InitializeUserDetailsManagerConfigurer extends GlobalAuthenticationConfigu public void configure(AuthenticationManagerBuilder auth) { String[] beanNames = InitializeUserDetailsBeanManagerConfigurer.this.context .getBeanNamesForType(UserDetailsService.class); + if (auth.isConfigured()) { if (beanNames.length > 0) { this.logger.warn("Global AuthenticationManager configured with an AuthenticationProvider bean. " @@ -83,18 +85,27 @@ public void configure(AuthenticationManagerBuilder auth) { if (beanNames.length == 0) { return; } - else if (beanNames.length > 1) { - this.logger.warn(LogMessage.format("Found %s UserDetailsService beans, with names %s. " - + "Global Authentication Manager will not use a UserDetailsService for username/password login. " - + "Consider publishing a single UserDetailsService bean.", beanNames.length, - Arrays.toString(beanNames))); + + // Try to resolve a single candidate using the container's rules (@Primary, + // etc.) + UserDetailsService userDetailsService = getAutowireCandidateOrNull(UserDetailsService.class); + + // If ambiguous or otherwise not resolvable, keep the warn-and-skip behavior + if (userDetailsService == null) { + if (beanNames.length > 1) { + this.logger.warn(LogMessage.format("Found %s UserDetailsService beans, with names %s. " + + "Global Authentication Manager will not use a UserDetailsService for username/password login. " + + "Consider publishing a single (or primary) UserDetailsService bean.", beanNames.length, + Arrays.toString(beanNames))); + } return; } - UserDetailsService userDetailsService = InitializeUserDetailsBeanManagerConfigurer.this.context - .getBean(beanNames[0], UserDetailsService.class); - PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class); - UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class); - CompromisedPasswordChecker passwordChecker = getBeanOrNull(CompromisedPasswordChecker.class); + + PasswordEncoder passwordEncoder = getBeanIfUnique(PasswordEncoder.class); + // Also resolve UDPS via container so @Primary is honored + UserDetailsPasswordService passwordManager = getAutowireCandidateOrNull(UserDetailsPasswordService.class); + CompromisedPasswordChecker passwordChecker = getBeanIfUnique(CompromisedPasswordChecker.class); + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); if (passwordEncoder != null) { provider.setPasswordEncoder(passwordEncoder); @@ -107,18 +118,47 @@ else if (beanNames.length > 1) { } provider.afterPropertiesSet(); auth.authenticationProvider(provider); + + String selectedName = resolveBeanName(beanNames, userDetailsService); this.logger.info(LogMessage.format( - "Global AuthenticationManager configured with UserDetailsService bean with name %s", beanNames[0])); + "Global AuthenticationManager configured with UserDetailsService bean with name %s", selectedName)); } /** - * @return a bean of the requested class if there's just a single registered - * component, null otherwise. + * Resolve a single autowire candidate for the given type (honors + * {@code @Primary}). Returns {@code null} if ambiguous or not present. */ - private T getBeanOrNull(Class type) { + private T getAutowireCandidateOrNull(Class type) { + try { + return InitializeUserDetailsBeanManagerConfigurer.this.context.getBeanProvider(type).getIfAvailable(); + } + catch (BeansException ex) { + return null; + } + } + + /** + * Return a bean of the requested class if there's exactly one registered + * component; {@code null} otherwise. + */ + private T getBeanIfUnique(Class type) { return InitializeUserDetailsBeanManagerConfigurer.this.context.getBeanProvider(type).getIfUnique(); } + private String resolveBeanName(String[] candidates, Object instance) { + for (String name : candidates) { + try { + Object bean = InitializeUserDetailsBeanManagerConfigurer.this.context.getBean(name); + if (bean == instance) { + return name; + } + } + catch (BeansException ignored) { + } + } + return instance.getClass().getName(); + } + } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurerTests.java new file mode 100644 index 0000000000..7f1d3cd377 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurerTests.java @@ -0,0 +1,153 @@ +/* + * Copyright 2004-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.security.config.annotation.authentication.configuration; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.password.CompromisedPasswordChecker; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsPasswordService; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class InitializeUserDetailsBeanManagerConfigurerTests { + + private static ObjectPostProcessor opp() { + return new ObjectPostProcessor<>() { + @Override + public O postProcess(O object) { + return object; + } + }; + } + + @SuppressWarnings("unchecked") + @Test + void whenMultipleUdsAndOneResolvableCandidate_thenPrimaryIsAutoWired() throws Exception { + ApplicationContext ctx = mock(ApplicationContext.class); + given(ctx.getBeanNamesForType(UserDetailsService.class)).willReturn(new String[] { "udsA", "udsB" }); + + PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + InMemoryUserDetailsManager primary = new InMemoryUserDetailsManager( + User.withUsername("alice").passwordEncoder(encoder::encode).password("pw").roles("USER").build()); + InMemoryUserDetailsManager secondary = new InMemoryUserDetailsManager(); + + ObjectProvider udsProvider = (ObjectProvider) mock( + ObjectProvider.class); + given(ctx.getBeanProvider(UserDetailsService.class)).willReturn(udsProvider); + given(udsProvider.getIfAvailable()).willReturn(primary); // container picks single + // candidate + + // resolveBeanName(..) path + given(ctx.getBean("udsA")).willReturn(secondary); + given(ctx.getBean("udsB")).willReturn(primary); + + ObjectProvider peProvider = (ObjectProvider) mock(ObjectProvider.class); + given(ctx.getBeanProvider(PasswordEncoder.class)).willReturn(peProvider); + given(peProvider.getIfUnique()).willReturn(encoder); + + // Stub optional providers to avoid NPEs + ObjectProvider udpsProvider = (ObjectProvider) mock( + ObjectProvider.class); + given(ctx.getBeanProvider(UserDetailsPasswordService.class)).willReturn(udpsProvider); + given(udpsProvider.getIfAvailable()).willReturn(null); + + ObjectProvider cpcProvider = (ObjectProvider) mock( + ObjectProvider.class); + given(ctx.getBeanProvider(CompromisedPasswordChecker.class)).willReturn(cpcProvider); + given(cpcProvider.getIfUnique()).willReturn(null); + + AuthenticationManagerBuilder builder = new AuthenticationManagerBuilder(opp()); + new InitializeUserDetailsBeanManagerConfigurer(ctx).new InitializeUserDetailsManagerConfigurer() + .configure(builder); + + AuthenticationManager manager = builder.build(); + + // DaoAuthenticationProvider registered + assertThat(manager).isInstanceOf(ProviderManager.class); + List providers = ((ProviderManager) manager).getProviders(); + assertThat(providers) + .anySatisfy((p) -> assertThat(p.getClass().getSimpleName()).isEqualTo("DaoAuthenticationProvider")); + + // Auth works with the primary UDS + encoder + var auth = manager.authenticate(new UsernamePasswordAuthenticationToken("alice", "pw")); + assertThat(auth.isAuthenticated()).isTrue(); + } + + @SuppressWarnings("unchecked") + @Test + void whenMultipleUdsAndNoSingleCandidate_thenSkipAutoWiring() throws Exception { + ApplicationContext ctx = mock(ApplicationContext.class); + given(ctx.getBeanNamesForType(UserDetailsService.class)).willReturn(new String[] { "udsA", "udsB" }); + + ObjectProvider udsProvider = (ObjectProvider) mock( + ObjectProvider.class); + given(ctx.getBeanProvider(UserDetailsService.class)).willReturn(udsProvider); + given(udsProvider.getIfAvailable()).willReturn(null); // ambiguous → no single + // candidate + + // Also stub other providers to null + ObjectProvider peProvider = (ObjectProvider) mock(ObjectProvider.class); + given(ctx.getBeanProvider(PasswordEncoder.class)).willReturn(peProvider); + given(peProvider.getIfUnique()).willReturn(null); + + ObjectProvider udpsProvider = (ObjectProvider) mock( + ObjectProvider.class); + given(ctx.getBeanProvider(UserDetailsPasswordService.class)).willReturn(udpsProvider); + given(udpsProvider.getIfAvailable()).willReturn(null); + + ObjectProvider cpcProvider = (ObjectProvider) mock( + ObjectProvider.class); + given(ctx.getBeanProvider(CompromisedPasswordChecker.class)).willReturn(cpcProvider); + given(cpcProvider.getIfUnique()).willReturn(null); + + AuthenticationManagerBuilder builder = new AuthenticationManagerBuilder(opp()); + new InitializeUserDetailsBeanManagerConfigurer(ctx).new InitializeUserDetailsManagerConfigurer() + .configure(builder); + + AuthenticationManager manager = builder.build(); + + // Success condition: nothing auto-registered. + if (manager == null) { + assertThat(manager).isNull(); + } + else if (manager instanceof ProviderManager pm) { + assertThat(pm.getProviders()) + .noneMatch((p) -> p.getClass().getSimpleName().equals("DaoAuthenticationProvider")); + } + else { + assertThat(manager.getClass().getSimpleName()).isNotEqualTo("ProviderManager"); + } + } + +}