From bd7171140e592aa4049af758e0bd5b263693fdd4 Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:03:08 -0500 Subject: [PATCH] Support Customizer>> Closes gh-18922 --- ...horizationManagerFactoryConfiguration.java | 12 +- .../EnableMultiFactorAuthentication.java | 13 ++ ...tiFactorAuthenticationCustomizerTests.java | 122 +++++++++++++++ ...uthenticationMultipleCustomizersTests.java | 145 ++++++++++++++++++ .../pages/servlet/authentication/mfa.adoc | 9 ++ ...horizationManagerFactoryConfiguration.java | 7 + ...ultiFactorAuthenticationConfiguration.java | 1 + ...uthorizationManagerFactoryConfiguration.kt | 8 + ...eMultiFactorAuthenticationConfiguration.kt | 2 + 9 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationCustomizerTests.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationMultipleCustomizersTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/authorization/AuthorizationManagerFactoryConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/authorization/AuthorizationManagerFactoryConfiguration.java index 0b9b7bbc76..cc1543b3c6 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/authorization/AuthorizationManagerFactoryConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/authorization/AuthorizationManagerFactoryConfiguration.java @@ -16,13 +16,16 @@ package org.springframework.security.config.annotation.authorization; +import java.util.List; import java.util.Map; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ImportAware; import org.springframework.core.type.AnnotationMetadata; import org.springframework.security.authorization.AuthorizationManagerFactories; +import org.springframework.security.authorization.AuthorizationManagerFactories.AdditionalRequiredFactorsBuilder; import org.springframework.security.authorization.DefaultAuthorizationManagerFactory; +import org.springframework.security.config.Customizer; /** * Uses {@link EnableMultiFactorAuthentication} to configure a @@ -37,10 +40,13 @@ class AuthorizationManagerFactoryConfiguration implements ImportAware { private String[] authorities; @Bean - DefaultAuthorizationManagerFactory authorizationManagerFactory() { - AuthorizationManagerFactories.AdditionalRequiredFactorsBuilder builder = AuthorizationManagerFactories - .multiFactor() + DefaultAuthorizationManagerFactory authorizationManagerFactory( + List>> additionalRequiredFactorsCustomizers) { + AdditionalRequiredFactorsBuilder builder = AuthorizationManagerFactories.multiFactor() .requireFactors(this.authorities); + for (Customizer> customizer : additionalRequiredFactorsCustomizers) { + customizer.customize(builder); + } return builder.build(); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthentication.java b/config/src/main/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthentication.java index d2b0c25426..2e04ef708e 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthentication.java +++ b/config/src/main/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthentication.java @@ -45,6 +45,19 @@ import org.springframework.security.authorization.DefaultAuthorizationManagerFac * } * * + *

+ * You can also publish one or more + * {@code Customizer>} beans to further customize + * the {@link DefaultAuthorizationManagerFactory}. For example, conditionally applying MFA + * for specific users: + * + *

+ * @Bean
+ * Customizer<AuthorizationManagerFactories.AdditionalRequiredFactorsBuilder<Object>> additionalRequiredFactorsCustomizer() {
+ *     return (builder) -> builder.when((auth) -> "admin".equals(auth.getName()));
+ * }
+ * 
+ * * NOTE: At this time reactive applications do not support MFA and thus are not impacted. * This will likely be enhanced in the future. * diff --git a/config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationCustomizerTests.java b/config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationCustomizerTests.java new file mode 100644 index 0000000000..ae3e25ded1 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationCustomizerTests.java @@ -0,0 +1,122 @@ +/* + * 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.authorization; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationManagerFactories; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link EnableMultiFactorAuthentication} with + * {@link Customizer}<{@link AuthorizationManagerFactories.AdditionalRequiredFactorsBuilder}>. + */ +@ExtendWith(SpringExtension.class) +@WebAppConfiguration +@ContextConfiguration(classes = EnableMultiFactorAuthenticationCustomizerTests.ConfigWithCustomizer.class) +class EnableMultiFactorAuthenticationCustomizerTests { + + @Autowired + MockMvc mvc; + + @Test + @WithMockUser(username = "user", authorities = "ROLE_USER") + void whenCustomizerAppliedThenConditionalMfaUsed() throws Exception { + this.mvc.perform(get("/")).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "admin", authorities = "ROLE_USER") + void whenCustomizerAppliedAndConditionTrueThenMfaRequired() throws Exception { + this.mvc.perform(get("/")).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "admin", authorities = { "ROLE_USER", FactorGrantedAuthority.PASSWORD_AUTHORITY, + FactorGrantedAuthority.OTT_AUTHORITY }) + void whenCustomizerAppliedAndConditionTrueWithMfaThenAuthorized() throws Exception { + this.mvc.perform(get("/")).andExpect(status().isOk()); + } + + @EnableWebSecurity + @Configuration + @EnableMultiFactorAuthentication( + authorities = { FactorGrantedAuthority.OTT_AUTHORITY, FactorGrantedAuthority.PASSWORD_AUTHORITY }) + static class ConfigWithCustomizer { + + @Bean + Customizer> additionalRequiredFactorsCustomizer() { + return (builder) -> builder.when((auth) -> "admin".equals(auth.getName())); + } + + @Bean + MockMvc mvc(WebApplicationContext context) { + return MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + @Bean + @SuppressWarnings("deprecation") + UserDetailsService userDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + UserDetails admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("USER") + .build(); + return new InMemoryUserDetailsManager(user, admin); + } + + @RestController + static class OkController { + + @GetMapping("/") + String ok() { + return "ok"; + } + + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationMultipleCustomizersTests.java b/config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationMultipleCustomizersTests.java new file mode 100644 index 0000000000..9dfe61081e --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/authorization/EnableMultiFactorAuthenticationMultipleCustomizersTests.java @@ -0,0 +1,145 @@ +/* + * 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.authorization; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authorization.AuthorizationManagerFactories; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link EnableMultiFactorAuthentication} with multiple + * {@link Customizer}<{@link AuthorizationManagerFactories.AdditionalRequiredFactorsBuilder}> + * beans. + */ +@ExtendWith(SpringExtension.class) +@WebAppConfiguration +@ContextConfiguration( + classes = EnableMultiFactorAuthenticationMultipleCustomizersTests.ConfigWithMultipleCustomizers.class) +class EnableMultiFactorAuthenticationMultipleCustomizersTests { + + @Autowired + MockMvc mvc; + + @Test + @WithMockUser(username = "user", authorities = "ROLE_USER") + void whenCustomizerAppliedThenConditionalMfaUsed() throws Exception { + this.mvc.perform(get("/")).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "admin", authorities = "ROLE_USER") + void whenCustomizersAppliedAndConditionTrueThenMfaRequired() throws Exception { + this.mvc.perform(get("/")).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "admin", authorities = { "ROLE_USER", FactorGrantedAuthority.PASSWORD_AUTHORITY, + FactorGrantedAuthority.OTT_AUTHORITY }) + void whenCustomizersAppliedAndConditionTrueWithMfaThenAuthorized() throws Exception { + this.mvc.perform(get("/")).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "manager", authorities = "ROLE_USER") + void whenSecondCustomizerAppliedAndConditionTrueThenMfaRequired() throws Exception { + this.mvc.perform(get("/")).andExpect(status().isUnauthorized()); + } + + @EnableWebSecurity + @Configuration + @EnableMultiFactorAuthentication( + authorities = { FactorGrantedAuthority.OTT_AUTHORITY, FactorGrantedAuthority.PASSWORD_AUTHORITY }) + static class ConfigWithMultipleCustomizers { + + @Bean + @Order(1) + Customizer> adminCustomizer() { + return (builder) -> builder.withWhen( + (current) -> (auth) -> "admin".equals(auth.getName()) || (current != null && current.test(auth))); + } + + @Bean + @Order(2) + Customizer> managerCustomizer() { + return (builder) -> builder.withWhen( + (current) -> (auth) -> "manager".equals(auth.getName()) || (current != null && current.test(auth))); + } + + @Bean + MockMvc mvc(WebApplicationContext context) { + return MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + @Bean + @SuppressWarnings("deprecation") + UserDetailsService userDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + UserDetails admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("USER") + .build(); + UserDetails manager = User.withDefaultPasswordEncoder() + .username("manager") + .password("password") + .roles("USER") + .build(); + return new InMemoryUserDetailsManager(user, admin, manager); + } + + @RestController + static class OkController { + + @GetMapping("/") + String ok() { + return "ok"; + } + + } + + } + +} diff --git a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc index bce3d43237..d2f7e0d054 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc @@ -37,6 +37,14 @@ Spring Security behind the scenes knows which endpoint to go to depending on whi If the user logged in initially with their username and password, then Spring Security redirects to the One-Time-Token Login page. If the user logged in initially with a token, then Spring Security redirects to the Username/Password Login page. +[[mfa-when-custom-conditions]] +=== Custom MFA Conditions + +You can also publish one or more `Customizer>` beans to customize the factory created by `@EnableMultiFactorAuthentication`. +For example, you can conditionally apply MFA for specific users: + +include-code::./CustomizerAuthorizationManagerFactoryConfiguration[tag=customizer,indent=0] + [[authorization-manager-factory]] == AuthorizationManagerFactory @@ -48,6 +56,7 @@ The `AuthorizationManagerFactory` Bean below is what is published in the previou include-code::./UseAuthorizationManagerFactoryConfiguration[tag=authorizationManagerFactoryBean,indent=0] + [[selective-mfa]] == Selectively Requiring MFA diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java index c4c9e046a1..82badc0faf 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java @@ -47,6 +47,13 @@ class UseAuthorizationManagerFactoryConfiguration { } // end::authorizationManagerFactoryBean[] + // tag::customizer[] + @Bean + Customizer> additionalRequiredFactorsCustomizer() { + return (builder) -> builder.when((auth) -> "admin".equals(auth.getName())); + } + // end::customizer[] + @Bean UserDetailsService userDetailsService() { return new InMemoryUserDetailsManager( diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.java index 63f27eeb5b..ae701e0002 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.java @@ -2,6 +2,7 @@ package org.springframework.security.docs.servlet.authentication.emfa; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationManagerFactories; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication; import org.springframework.security.config.annotation.web.builders.HttpSecurity; diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt index 272f58c2c3..5140116851 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.authorization.AuthorizationManagerFactories import org.springframework.security.authorization.AuthorizationManagerFactory +import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.invoke @@ -47,6 +48,13 @@ internal class UseAuthorizationManagerFactoryConfiguration { } // end::authorizationManagerFactoryBean[] + // tag::customizer[] + @Bean + fun additionalRequiredFactorsCustomizer(): Customizer> { + return Customizer { builder -> builder.`when` { auth -> "admin" == auth.name } } + } + // end::customizer[] + @Suppress("DEPRECATION") @Bean fun userDetailsService(): UserDetailsService { diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.kt index 4495769c71..426967f0f7 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/emfa/EnableMultiFactorAuthenticationConfiguration.kt @@ -2,6 +2,8 @@ package org.springframework.security.kt.docs.servlet.authentication.emfa import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.security.authorization.AuthorizationManagerFactories +import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity