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 6f3d7141447..a749f937150 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 @@ -6,12 +6,6 @@ * 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; @@ -33,12 +27,15 @@ 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 it is not yet configured. Honors {@code @Primary} when multiple + * {@link UserDetailsService} (or {@link UserDetailsPasswordService}) beans are present. + * If a single {@link PasswordEncoder} or {@link CompromisedPasswordChecker} bean is + * available, those are wired as well. * * @author Rob Winch * @author Ngoc Nhan + * @author You * @since 4.1 */ @Order(InitializeUserDetailsBeanManagerConfigurer.DEFAULT_ORDER) @@ -48,9 +45,6 @@ class InitializeUserDetailsBeanManagerConfigurer extends GlobalAuthenticationCon private final ApplicationContext context; - /** - * @param context - */ InitializeUserDetailsBeanManagerConfigurer(ApplicationContext context) { this.context = context; } @@ -67,34 +61,56 @@ class InitializeUserDetailsManagerConfigurer extends GlobalAuthenticationConfigu @Override public void configure(AuthenticationManagerBuilder auth) { String[] beanNames = InitializeUserDetailsBeanManagerConfigurer.this.context - .getBeanNamesForType(UserDetailsService.class); + .getBeanNamesForType(UserDetailsService.class); + + // If user configured an AuthenticationProvider already, warn and bail if (auth.isConfigured()) { if (beanNames.length > 0) { - this.logger.warn("Global AuthenticationManager configured with an AuthenticationProvider bean. " - + "UserDetailsService beans will not be used by Spring Security for automatically configuring username/password login. " - + "Consider removing the AuthenticationProvider bean. " - + "Alternatively, consider using the UserDetailsService in a manually instantiated DaoAuthenticationProvider. " - + "If the current configuration is intentional, to turn off this warning, " - + "increase the logging level of 'org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer' to ERROR"); + this.logger.warn( + "Global AuthenticationManager configured with an AuthenticationProvider bean. " + + "UserDetailsService beans will not be used by Spring Security for automatically configuring username/password login. " + + "Consider removing the AuthenticationProvider bean or configure DaoAuthenticationProvider manually."); } return; } + // No UDS beans — nothing to do 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 autowire-candidate UDS from the container. + * getIfAvailable() returns: + * - the bean if there is exactly one, or + * - the @Primary bean if there are multiple and one is marked primary, + * - otherwise null. + */ + UserDetailsService userDetailsService = getAutowireCandidateOrNull(UserDetailsService.class); + + // If still ambiguous and we have multiple beans, keep current (warn + skip) + if (userDetailsService == null && 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); + + // If there is exactly one bean and getIfAvailable returned null (shouldn't happen), + // fall back to retrieving that single bean by name. + if (userDetailsService == null) { + userDetailsService = InitializeUserDetailsBeanManagerConfigurer.this.context + .getBean(beanNames[0], UserDetailsService.class); + } + + PasswordEncoder passwordEncoder = getBeanIfUnique(PasswordEncoder.class); + // Honor @Primary for UDPS as well + UserDetailsPasswordService passwordManager = getAutowireCandidateOrNull(UserDetailsPasswordService.class); + // Keep "unique only" semantics for optional checker + CompromisedPasswordChecker passwordChecker = getBeanIfUnique(CompromisedPasswordChecker.class); + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); if (passwordEncoder != null) { provider.setPasswordEncoder(passwordEncoder); @@ -106,19 +122,20 @@ else if (beanNames.length > 1) { provider.setCompromisedPasswordChecker(passwordChecker); } provider.afterPropertiesSet(); + auth.authenticationProvider(provider); + this.logger.info(LogMessage.format( - "Global AuthenticationManager configured with UserDetailsService bean with name %s", beanNames[0])); + "Global AuthenticationManager configured with UserDetailsService bean (auto-selected).")); } - /** - * @return a bean of the requested class if there's just a single registered - * component, null otherwise. - */ - private T getBeanOrNull(Class type) { - return InitializeUserDetailsBeanManagerConfigurer.this.context.getBeanProvider(type).getIfUnique(); + private T getAutowireCandidateOrNull(Class type) { + return InitializeUserDetailsBeanManagerConfigurer.this.context.getBeanProvider(type).getIfAvailable(); } + private T getBeanIfUnique(Class type) { + return InitializeUserDetailsBeanManagerConfigurer.this.context.getBeanProvider(type).getIfUnique(); + } } } 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 00000000000..1b51a89cccf --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurerTests.java @@ -0,0 +1,146 @@ +/* + * 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 + */ + +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"); + } + } +}