浏览代码

MFA is now Opt In

This commit ensures that MFA is only performed when users opt in. By
doing so, we allow users to decide if they will opt into the semantics
of merging two Authentication instances.

Closes gh-18126
Rob Winch 5 天之前
父节点
当前提交
aaf738f7ac
共有 14 个文件被更改,包括 472 次插入4 次删除
  1. 2 2
      config/src/main/java/org/springframework/security/config/annotation/authorization/AuthorizationManagerFactoryConfiguration.java
  2. 4 2
      config/src/main/java/org/springframework/security/config/annotation/authorization/EnableGlobalMultiFactorAuthentication.java
  3. 65 0
      config/src/main/java/org/springframework/security/config/annotation/authorization/EnableMfaFiltersConfiguration.java
  4. 50 0
      config/src/main/java/org/springframework/security/config/annotation/authorization/GlobalMultiFactorAuthenticationSelector.java
  5. 158 0
      config/src/test/java/org/springframework/security/config/annotation/authorization/EnableGlobalMultiFactorAuthenticationFiltersSetTests.java
  6. 54 0
      config/src/test/java/org/springframework/security/config/annotation/authorization/EnableGlobalMultiFactorAuthenticationTests.java
  7. 14 0
      web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilter.java
  8. 14 0
      web/src/main/java/org/springframework/security/web/authentication/AuthenticationFilter.java
  9. 14 0
      web/src/main/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilter.java
  10. 14 0
      web/src/main/java/org/springframework/security/web/authentication/www/BasicAuthenticationFilter.java
  11. 20 0
      web/src/test/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilterTests.java
  12. 24 0
      web/src/test/java/org/springframework/security/web/authentication/AuthenticationFilterTests.java
  13. 19 0
      web/src/test/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilterTests.java
  14. 20 0
      web/src/test/java/org/springframework/security/web/authentication/www/BasicAuthenticationFilterTests.java

+ 2 - 2
config/src/main/java/org/springframework/security/config/annotation/authorization/GlobalMultiFactorAuthenticationConfiguration.java → config/src/main/java/org/springframework/security/config/annotation/authorization/AuthorizationManagerFactoryConfiguration.java

@@ -34,7 +34,7 @@ import org.springframework.security.authorization.DefaultAuthorizationManagerFac
  * @since 7.0
  * @see EnableGlobalMultiFactorAuthentication
  */
-class GlobalMultiFactorAuthenticationConfiguration implements ImportAware {
+class AuthorizationManagerFactoryConfiguration implements ImportAware {
 
 	private String[] authorities;
 
@@ -51,7 +51,7 @@ class GlobalMultiFactorAuthenticationConfiguration implements ImportAware {
 		Map<String, Object> multiFactorAuthenticationAttrs = importMetadata
 			.getAnnotationAttributes(EnableGlobalMultiFactorAuthentication.class.getName());
 
-		this.authorities = (String[]) multiFactorAuthenticationAttrs.get("authorities");
+		this.authorities = (String[]) multiFactorAuthenticationAttrs.getOrDefault("authorities", new String[0]);
 	}
 
 }

+ 4 - 2
config/src/main/java/org/springframework/security/config/annotation/authorization/EnableGlobalMultiFactorAuthentication.java

@@ -51,13 +51,15 @@ import org.springframework.security.authorization.DefaultAuthorizationManagerFac
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.TYPE)
 @Documented
-@Import(GlobalMultiFactorAuthenticationConfiguration.class)
+@Import(GlobalMultiFactorAuthenticationSelector.class)
 public @interface EnableGlobalMultiFactorAuthentication {
 
 	/**
 	 * The additional authorities that are required.
 	 * @return the additional authorities that are required (e.g. {
-	 * FactorGrantedAuthority.FACTOR_OTT, FactorGrantedAuthority.FACTOR_PASSWORD })
+	 * FactorGrantedAuthority.FACTOR_OTT, FactorGrantedAuthority.FACTOR_PASSWORD }). Can
+	 * be null or an empty array if no additional authorities are required (if
+	 * authorization rules are not globally requiring MFA).
 	 * @see org.springframework.security.core.authority.FactorGrantedAuthority
 	 */
 	String[] authorities();

+ 65 - 0
config/src/main/java/org/springframework/security/config/annotation/authorization/EnableMfaFiltersConfiguration.java

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2002-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.jspecify.annotations.Nullable;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.authentication.AuthenticationFilter;
+import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
+import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
+
+@Configuration(proxyBeanMethods = false)
+class EnableMfaFiltersConfiguration {
+
+	@Bean
+	BeanPostProcessor mfaBeanPostProcessor() {
+		return new EnableMfaFiltersPostProcessor();
+	}
+
+	/**
+	 * A {@link BeanPostProcessor} that enables MFA on authentication filters.
+	 *
+	 * @author Rob Winch
+	 * @since 7.0
+	 */
+	private static class EnableMfaFiltersPostProcessor implements BeanPostProcessor {
+
+		@Override
+		public @Nullable Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
+			if (bean instanceof AbstractAuthenticationProcessingFilter filter) {
+				filter.setMfaEnabled(true);
+			}
+			if (bean instanceof AuthenticationFilter filter) {
+				filter.setMfaEnabled(true);
+			}
+			if (bean instanceof AbstractPreAuthenticatedProcessingFilter filter) {
+				filter.setMfaEnabled(true);
+			}
+			if (bean instanceof BasicAuthenticationFilter filter) {
+				filter.setMfaEnabled(true);
+			}
+			return bean;
+		}
+
+	}
+
+}

+ 50 - 0
config/src/main/java/org/springframework/security/config/annotation/authorization/GlobalMultiFactorAuthenticationSelector.java

@@ -0,0 +1,50 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.context.annotation.ImportSelector;
+import org.springframework.core.type.AnnotationMetadata;
+import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
+
+/**
+ * Uses {@link EnableGlobalMultiFactorAuthentication} to configure a
+ * {@link DefaultAuthorizationManagerFactory}.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ * @see EnableGlobalMultiFactorAuthentication
+ */
+class GlobalMultiFactorAuthenticationSelector implements ImportSelector {
+
+	@Override
+	public String[] selectImports(AnnotationMetadata metadata) {
+		Map<String, Object> multiFactorAuthenticationAttrs = metadata
+			.getAnnotationAttributes(EnableGlobalMultiFactorAuthentication.class.getName());
+		String[] authorities = (String[]) multiFactorAuthenticationAttrs.getOrDefault("authorities", new String[0]);
+		List<String> imports = new ArrayList<>(2);
+		if (authorities.length > 0) {
+			imports.add(AuthorizationManagerFactoryConfiguration.class.getName());
+		}
+		imports.add(EnableMfaFiltersConfiguration.class.getName());
+		return imports.toArray(new String[imports.size()]);
+	}
+
+}

+ 158 - 0
config/src/test/java/org/springframework/security/config/annotation/authorization/EnableGlobalMultiFactorAuthenticationFiltersSetTests.java

@@ -0,0 +1,158 @@
+/*
+ * 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 jakarta.servlet.Filter;
+import jakarta.servlet.http.HttpServletRequest;
+import org.jspecify.annotations.Nullable;
+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.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockServletContext;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.FactorGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.authentication.AuthenticationFilter;
+import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
+import org.springframework.security.web.authentication.www.BasicAuthenticationConverter;
+import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link EnableGlobalMultiFactorAuthentication}.
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringExtension.class)
+@WebAppConfiguration
+@WithMockUser(authorities = FactorGrantedAuthority.PASSWORD_AUTHORITY)
+public class EnableGlobalMultiFactorAuthenticationFiltersSetTests {
+
+	@Autowired
+	private AuthenticationManager manager;
+
+	private TestingAuthenticationToken newAuthn = new TestingAuthenticationToken("user", "password", "ROLE_USER",
+			FactorGrantedAuthority.OTT_AUTHORITY);
+
+	@Test
+	void preAuthenticationFilter(@Autowired AbstractAuthenticationProcessingFilter filter) throws Exception {
+		assertMfaEnabled(filter);
+	}
+
+	@Test
+	void authenticationFilter(@Autowired AuthenticationFilter filter) throws Exception {
+		assertMfaEnabled(filter);
+	}
+
+	@Test
+	void preAuthnFilter(@Autowired AbstractPreAuthenticatedProcessingFilter filter) throws Exception {
+		assertMfaEnabled(filter);
+	}
+
+	@Test
+	void basicAuthnFilter(@Autowired BasicAuthenticationFilter filter) throws Exception {
+		assertMfaEnabled(filter);
+	}
+
+	private void assertMfaEnabled(Filter filter) throws Exception {
+		given(this.manager.authenticate(any())).willReturn(this.newAuthn);
+		MockHttpServletRequest request = MockMvcRequestBuilders.get("/")
+			.headers((headers) -> headers.setBasicAuth("u", "p"))
+			.buildRequest(new MockServletContext());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		MockFilterChain chain = new MockFilterChain();
+		filter.doFilter(request, response, chain);
+		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getAuthorities()).extracting(GrantedAuthority::getAuthority)
+			.containsExactlyInAnyOrder(FactorGrantedAuthority.OTT_AUTHORITY, FactorGrantedAuthority.PASSWORD_AUTHORITY,
+					"ROLE_USER");
+	}
+
+	@EnableWebSecurity
+	@Configuration
+	@EnableGlobalMultiFactorAuthentication(
+			authorities = { FactorGrantedAuthority.OTT_AUTHORITY, FactorGrantedAuthority.PASSWORD_AUTHORITY })
+	static class Config {
+
+		@Bean
+		AuthenticationManager authenticationManager() {
+			return mock(AuthenticationManager.class);
+		}
+
+		@Bean
+		static AbstractAuthenticationProcessingFilter authnProcessingFilter(
+				AuthenticationManager authenticationManager) {
+			AbstractAuthenticationProcessingFilter result = new AbstractAuthenticationProcessingFilter(
+					AnyRequestMatcher.INSTANCE, authenticationManager) {
+			};
+			result.setAuthenticationConverter(new BasicAuthenticationConverter());
+			return result;
+		}
+
+		@Bean
+		static AuthenticationFilter authenticationFilter(AuthenticationManager authenticationManager) {
+			return new AuthenticationFilter(authenticationManager, new BasicAuthenticationConverter());
+		}
+
+		@Bean
+		static AbstractPreAuthenticatedProcessingFilter preAuthenticatedProcessingFilter(
+				AuthenticationManager authenticationManager) {
+			AbstractPreAuthenticatedProcessingFilter result = new AbstractPreAuthenticatedProcessingFilter() {
+				@Override
+				protected @Nullable Object getPreAuthenticatedCredentials(HttpServletRequest request) {
+					return "password";
+				}
+
+				@Override
+				protected @Nullable Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
+					return "user";
+				}
+			};
+			result.setRequiresAuthenticationRequestMatcher(AnyRequestMatcher.INSTANCE);
+			result.setAuthenticationManager(authenticationManager);
+			return result;
+		}
+
+		@Bean
+		static BasicAuthenticationFilter basicAuthenticationFilter(AuthenticationManager authenticationManager) {
+			return new BasicAuthenticationFilter(authenticationManager);
+		}
+
+	}
+
+}

+ 54 - 0
config/src/test/java/org/springframework/security/config/annotation/authorization/EnableGlobalMultiFactorAuthenticationTests.java

@@ -16,6 +16,13 @@
 
 package org.springframework.security.config.annotation.authorization;
 
+import java.io.IOException;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -25,10 +32,18 @@ import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.access.AccessDeniedException;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.config.Customizer;
 import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.core.authority.FactorGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+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.security.web.context.SecurityContextHolderFilter;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 import org.springframework.test.context.web.WebAppConfiguration;
 import org.springframework.test.web.servlet.MockMvc;
@@ -37,6 +52,8 @@ 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.request.SecurityMockMvcRequestBuilders.formLogin;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
 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;
@@ -50,12 +67,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 @WebAppConfiguration
 public class EnableGlobalMultiFactorAuthenticationTests {
 
+	private static final String ATTR_NAME = "org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors$SecurityContextRequestPostProcessorSupport$TestSecurityContextRepository.REPO";
+
 	@Autowired
 	MockMvc mvc;
 
 	@Autowired
 	Service service;
 
+	@Test
+	@WithMockUser(authorities = { "ROLE_USER", FactorGrantedAuthority.OTT_AUTHORITY })
+	public void formLoginWhenAuthenticatedThenMergedAuthorities() throws Exception {
+		this.mvc.perform(formLogin())
+			.andExpect(authenticated().withAuthorities("ROLE_USER", FactorGrantedAuthority.OTT_AUTHORITY,
+					FactorGrantedAuthority.PASSWORD_AUTHORITY));
+	}
+
 	@Test
 	@WithMockUser(authorities = { FactorGrantedAuthority.PASSWORD_AUTHORITY, FactorGrantedAuthority.OTT_AUTHORITY })
 	void webWhenAuthorized() throws Exception {
@@ -98,6 +125,33 @@ public class EnableGlobalMultiFactorAuthenticationTests {
 			return MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
 		}
 
+		@Bean
+		static Customizer<HttpSecurity> captureAuthn() {
+			return (http) -> http.addFilterAfter(new Filter() {
+				@Override
+				public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
+						FilterChain filterChain) throws IOException, ServletException {
+					try {
+						filterChain.doFilter(servletRequest, servletResponse);
+					}
+					finally {
+						servletRequest.setAttribute(ATTR_NAME, SecurityContextHolder.getContext());
+					}
+				}
+			}, SecurityContextHolderFilter.class);
+		}
+
+		@Bean
+		@SuppressWarnings("deprecation")
+		UserDetailsService userDetailsService() {
+			UserDetails user = User.withDefaultPasswordEncoder()
+				.username("user")
+				.password("password")
+				.roles("USER")
+				.build();
+			return new InMemoryUserDetailsManager(user);
+		}
+
 		@RestController
 		static class OkController {
 

+ 14 - 0
web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilter.java

@@ -158,6 +158,8 @@ public abstract class AbstractAuthenticationProcessingFilter extends GenericFilt
 
 	private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
 
+	private boolean mfaEnabled;
+
 	/**
 	 * @param defaultFilterProcessesUrl the default value for <tt>filterProcessesUrl</tt>.
 	 */
@@ -289,6 +291,9 @@ public abstract class AbstractAuthenticationProcessingFilter extends GenericFilt
 
 	@Contract("null, _ -> false")
 	private boolean shouldPerformMfa(@Nullable Authentication current, Authentication authenticationResult) {
+		if (!this.mfaEnabled) {
+			return false;
+		}
 		if (current == null || !current.isAuthenticated()) {
 			return false;
 		}
@@ -491,6 +496,15 @@ public abstract class AbstractAuthenticationProcessingFilter extends GenericFilt
 		this.allowSessionCreation = allowSessionCreation;
 	}
 
+	/**
+	 * Enables Multi-Factor Authentication (MFA) support.
+	 * @param mfaEnabled true to enable MFA support, false to disable it. Default is
+	 * false.
+	 */
+	public void setMfaEnabled(boolean mfaEnabled) {
+		this.mfaEnabled = mfaEnabled;
+	}
+
 	/**
 	 * The session handling strategy which will be invoked immediately after an
 	 * authentication request is successfully processed by the

+ 14 - 0
web/src/main/java/org/springframework/security/web/authentication/AuthenticationFilter.java

@@ -91,6 +91,8 @@ public class AuthenticationFilter extends OncePerRequestFilter {
 
 	private AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver;
 
+	private boolean mfaEnabled;
+
 	public AuthenticationFilter(AuthenticationManager authenticationManager,
 			AuthenticationConverter authenticationConverter) {
 		this((AuthenticationManagerResolver<HttpServletRequest>) (r) -> authenticationManager, authenticationConverter);
@@ -117,6 +119,15 @@ public class AuthenticationFilter extends OncePerRequestFilter {
 		return this.authenticationConverter;
 	}
 
+	/**
+	 * Enables Multi-Factor Authentication (MFA) support.
+	 * @param mfaEnabled true to enable MFA support, false to disable it. Default is
+	 * false.
+	 */
+	public void setMfaEnabled(boolean mfaEnabled) {
+		this.mfaEnabled = mfaEnabled;
+	}
+
 	public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) {
 		Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
 		this.authenticationConverter = authenticationConverter;
@@ -219,6 +230,9 @@ public class AuthenticationFilter extends OncePerRequestFilter {
 
 	@Contract("null, _ -> false")
 	private boolean shouldPerformMfa(@Nullable Authentication current, Authentication authenticationResult) {
+		if (!this.mfaEnabled) {
+			return false;
+		}
 		if (current == null || !current.isAuthenticated()) {
 			return false;
 		}

+ 14 - 0
web/src/main/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilter.java

@@ -123,6 +123,8 @@ public abstract class AbstractPreAuthenticatedProcessingFilter extends GenericFi
 
 	private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
 
+	private boolean mfaEnabled;
+
 	/**
 	 * Check whether all required properties have been set.
 	 */
@@ -238,6 +240,9 @@ public abstract class AbstractPreAuthenticatedProcessingFilter extends GenericFi
 
 	@Contract("null, _ -> false")
 	private boolean shouldPerformMfa(@Nullable Authentication current, Authentication authenticationResult) {
+		if (!this.mfaEnabled) {
+			return false;
+		}
 		if (current == null || !current.isAuthenticated()) {
 			return false;
 		}
@@ -299,6 +304,15 @@ public abstract class AbstractPreAuthenticatedProcessingFilter extends GenericFi
 		this.eventPublisher = anApplicationEventPublisher;
 	}
 
+	/**
+	 * Enables Multi-Factor Authentication (MFA) support.
+	 * @param mfaEnabled true to enable MFA support, false to disable it. Default is
+	 * false.
+	 */
+	public void setMfaEnabled(boolean mfaEnabled) {
+		this.mfaEnabled = mfaEnabled;
+	}
+
 	/**
 	 * Sets the {@link SecurityContextRepository} to save the {@link SecurityContext} on
 	 * authentication success. The default action is to save the {@link SecurityContext}

+ 14 - 0
web/src/main/java/org/springframework/security/web/authentication/www/BasicAuthenticationFilter.java

@@ -116,6 +116,8 @@ public class BasicAuthenticationFilter extends OncePerRequestFilter {
 
 	private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
 
+	private boolean mfaEnabled;
+
 	/**
 	 * Creates an instance which will authenticate against the supplied
 	 * {@code AuthenticationManager} and which will ignore failed authentication attempts,
@@ -156,6 +158,15 @@ public class BasicAuthenticationFilter extends OncePerRequestFilter {
 		this.securityContextRepository = securityContextRepository;
 	}
 
+	/**
+	 * Enables Multi-Factor Authentication (MFA) support.
+	 * @param mfaEnabled true to enable MFA support, false to disable it. Default is
+	 * false.
+	 */
+	public void setMfaEnabled(boolean mfaEnabled) {
+		this.mfaEnabled = mfaEnabled;
+	}
+
 	/**
 	 * Sets the
 	 * {@link org.springframework.security.web.authentication.AuthenticationConverter} to
@@ -238,6 +249,9 @@ public class BasicAuthenticationFilter extends OncePerRequestFilter {
 
 	@Contract("null, _ -> false")
 	private boolean shouldPerformMfa(@Nullable Authentication current, Authentication authenticationResult) {
+		if (!this.mfaEnabled) {
+			return false;
+		}
 		if (current == null || !current.isAuthenticated()) {
 			return false;
 		}

+ 20 - 0
web/src/test/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilterTests.java

@@ -457,12 +457,29 @@ public class AbstractAuthenticationProcessingFilterTests {
 		Authentication newAuthn = UsernamePasswordAuthenticationToken.authenticated(existingAuthn.getName(), "test",
 				AuthorityUtils.createAuthorityList("TEST"));
 		MockAuthenticationFilter filter = new MockAuthenticationFilter(newAuthn);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, new MockFilterChain(false));
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication.getAuthorities()).extracting(GrantedAuthority::getAuthority)
 			.containsExactlyInAnyOrder(ROLE_EXISTING, "TEST");
 	}
 
+	@Test
+	void doFilterWhenDefaultThenMfaDisabled() throws Exception {
+		String ROLE_EXISTING = "ROLE_EXISTING";
+		TestingAuthenticationToken existingAuthn = new TestingAuthenticationToken("username", "password",
+				ROLE_EXISTING);
+		SecurityContextHolder.setContext(new SecurityContextImpl(existingAuthn));
+		MockHttpServletRequest request = createMockAuthenticationRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Authentication newAuthn = UsernamePasswordAuthenticationToken.authenticated(existingAuthn.getName(), "test",
+				AuthorityUtils.createAuthorityList("TEST"));
+		MockAuthenticationFilter filter = new MockAuthenticationFilter(newAuthn);
+		filter.doFilter(request, response, new MockFilterChain(false));
+		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+		assertThat(authentication).isEqualTo(newAuthn);
+	}
+
 	// gh-18112
 	@Test
 	void doFilterWhenDifferentPrincipalThenDoesNotCombine() throws Exception {
@@ -475,6 +492,7 @@ public class AbstractAuthenticationProcessingFilterTests {
 		Authentication newAuthn = UsernamePasswordAuthenticationToken
 			.authenticated(existingAuthn.getName() + "different", "test", AuthorityUtils.createAuthorityList("TEST"));
 		MockAuthenticationFilter filter = new MockAuthenticationFilter(newAuthn);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, new MockFilterChain(false));
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication).isEqualTo(newAuthn);
@@ -494,6 +512,7 @@ public class AbstractAuthenticationProcessingFilterTests {
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		MockAuthenticationFilter filter = new MockAuthenticationFilter(
 				new TestingAuthenticationToken("username", "password", new DefaultEqualsGrantedAuthority()));
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, new MockFilterChain(false));
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(new ArrayList<GrantedAuthority>(authentication.getAuthorities()))
@@ -509,6 +528,7 @@ public class AbstractAuthenticationProcessingFilterTests {
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		MockAuthenticationFilter filter = new MockAuthenticationFilter(
 				new NonBuildableAuthenticationToken("username", "password", "FACTORTWO"));
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, new MockFilterChain(false));
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		SecurityAssertions.assertThat(authentication)

+ 24 - 0
web/src/test/java/org/springframework/security/web/authentication/AuthenticationFilterTests.java

@@ -320,12 +320,33 @@ public class AuthenticationFilterTests {
 		FilterChain chain = new MockFilterChain();
 		AuthenticationFilter filter = new AuthenticationFilter(this.authenticationManager,
 				this.authenticationConverter);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, chain);
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication.getAuthorities()).extracting(GrantedAuthority::getAuthority)
 			.containsExactlyInAnyOrder(ROLE_EXISTING, "TEST");
 	}
 
+	@Test
+	public void doFilterWhenDefaultThenMfaDisabled() throws Exception {
+		String ROLE_EXISTING = "ROLE_EXISTING";
+		TestingAuthenticationToken existingAuthn = new TestingAuthenticationToken("username", "password",
+				ROLE_EXISTING);
+		SecurityContextHolder.setContext(new SecurityContextImpl(existingAuthn));
+		given(this.authenticationConverter.convert(any())).willReturn(existingAuthn);
+		TestingAuthenticationToken newAuthn = new TestingAuthenticationToken(existingAuthn.getName(), "password",
+				"TEST");
+		given(this.authenticationManager.authenticate(any())).willReturn(newAuthn);
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", "/");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain chain = new MockFilterChain();
+		AuthenticationFilter filter = new AuthenticationFilter(this.authenticationManager,
+				this.authenticationConverter);
+		filter.doFilter(request, response, chain);
+		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+		assertThat(authentication).isEqualTo(newAuthn);
+	}
+
 	// gh-18112
 	@Test
 	public void doFilterWhenDifferentPrincipalThenDoesNotCombine() throws Exception {
@@ -342,6 +363,7 @@ public class AuthenticationFilterTests {
 		FilterChain chain = new MockFilterChain();
 		AuthenticationFilter filter = new AuthenticationFilter(this.authenticationManager,
 				this.authenticationConverter);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, chain);
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication).isEqualTo(expected);
@@ -365,6 +387,7 @@ public class AuthenticationFilterTests {
 		FilterChain chain = new MockFilterChain();
 		AuthenticationFilter filter = new AuthenticationFilter(this.authenticationManager,
 				this.authenticationConverter);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, chain);
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication.getAuthorities()).extracting(GrantedAuthority::getAuthority)
@@ -383,6 +406,7 @@ public class AuthenticationFilterTests {
 		FilterChain chain = new MockFilterChain();
 		AuthenticationFilter filter = new AuthenticationFilter(this.authenticationManager,
 				this.authenticationConverter);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, chain);
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		SecurityAssertions.assertThat(authentication)

+ 19 - 0
web/src/test/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilterTests.java

@@ -406,6 +406,7 @@ public class AbstractPreAuthenticatedProcessingFilterTests {
 		MockHttpServletRequest request = new MockHttpServletRequest();
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		this.filter = createFilterAuthenticatesWith(new TestingAuthenticationToken("username", "password", "TEST"));
+		this.filter.setMfaEnabled(true);
 		this.filter.doFilter(request, response, new MockFilterChain());
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		// @formatter:off
@@ -415,6 +416,21 @@ public class AbstractPreAuthenticatedProcessingFilterTests {
 		// @formatter:on
 	}
 
+	@Test
+	void doFilterWhenDefaultThenMfaDisabled() throws Exception {
+		String ROLE_EXISTING = "ROLE_EXISTING";
+		TestingAuthenticationToken existingAuthn = new TestingAuthenticationToken("username", "password",
+				ROLE_EXISTING);
+		SecurityContextHolder.setContext(new SecurityContextImpl(existingAuthn));
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		TestingAuthenticationToken newAuthn = new TestingAuthenticationToken("username", "password", "TEST");
+		this.filter = createFilterAuthenticatesWith(newAuthn);
+		this.filter.doFilter(request, response, new MockFilterChain());
+		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+		assertThat(authentication).isEqualTo(newAuthn);
+	}
+
 	// gh-18112
 	@Test
 	void doFilterWhenDifferentPrincipalThenDoesNotCombine() throws Exception {
@@ -427,6 +443,7 @@ public class AbstractPreAuthenticatedProcessingFilterTests {
 		TestingAuthenticationToken newAuthn = new TestingAuthenticationToken(existingAuthn.getName() + "different",
 				"password", "TEST");
 		this.filter = createFilterAuthenticatesWith(newAuthn);
+		this.filter.setMfaEnabled(true);
 		this.filter.doFilter(request, response, new MockFilterChain());
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication).isEqualTo(newAuthn);
@@ -446,6 +463,7 @@ public class AbstractPreAuthenticatedProcessingFilterTests {
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		this.filter = createFilterAuthenticatesWith(
 				new TestingAuthenticationToken("username", "password", new DefaultEqualsGrantedAuthority()));
+		this.filter.setMfaEnabled(true);
 		this.filter.doFilter(request, response, new MockFilterChain());
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		// @formatter:off
@@ -463,6 +481,7 @@ public class AbstractPreAuthenticatedProcessingFilterTests {
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		this.filter = createFilterAuthenticatesWith(
 				new NonBuildableAuthenticationToken("username", "password", "FACTORTWO"));
+		this.filter.setMfaEnabled(true);
 		this.filter.doFilter(request, response, new MockFilterChain());
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		SecurityAssertions.assertThat(authentication)

+ 20 - 0
web/src/test/java/org/springframework/security/web/authentication/www/BasicAuthenticationFilterTests.java

@@ -511,12 +511,31 @@ public class BasicAuthenticationFilterTests {
 		AuthenticationManager manager = mock(AuthenticationManager.class);
 		given(manager.authenticate(any())).willReturn(new TestingAuthenticationToken("username", "password", "TEST"));
 		BasicAuthenticationFilter filter = new BasicAuthenticationFilter(manager);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, new MockFilterChain());
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication.getAuthorities()).extracting(GrantedAuthority::getAuthority)
 			.containsExactlyInAnyOrder(ROLE_EXISTING, "TEST");
 	}
 
+	@Test
+	void doFilterWhenDefaultThenMfaDisabled() throws Exception {
+		String ROLE_EXISTING = "ROLE_EXISTING";
+		TestingAuthenticationToken existingAuthn = new TestingAuthenticationToken("username", "password",
+				ROLE_EXISTING);
+		SecurityContextHolder.setContext(new SecurityContextImpl(existingAuthn));
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader(HttpHeaders.AUTHORIZATION, "Basic " + CodecTestUtils.encodeBase64("a:b"));
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		AuthenticationManager manager = mock(AuthenticationManager.class);
+		TestingAuthenticationToken newAuthn = new TestingAuthenticationToken("username", "password", "TEST");
+		given(manager.authenticate(any())).willReturn(newAuthn);
+		BasicAuthenticationFilter filter = new BasicAuthenticationFilter(manager);
+		filter.doFilter(request, response, new MockFilterChain());
+		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+		assertThat(authentication).isEqualTo(newAuthn);
+	}
+
 	// gh-18112
 	@Test
 	void doFilterWhenDifferentPrincipalThenDoesNotCombine() throws Exception {
@@ -532,6 +551,7 @@ public class BasicAuthenticationFilterTests {
 				"password", "TEST");
 		given(manager.authenticate(any())).willReturn(newAuthn);
 		BasicAuthenticationFilter filter = new BasicAuthenticationFilter(manager);
+		filter.setMfaEnabled(true);
 		filter.doFilter(request, response, new MockFilterChain());
 		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 		assertThat(authentication).isEqualTo(newAuthn);