Skip to content

MFA: second-factor login replaces session authentication instead of merging factor authorities (flow can never complete) #313

@devondragon

Description

@devondragon

Summary

With user.mfa.enabled=true and factors: PASSWORD, WEBAUTHN, completing the WebAuthn challenge after a password login replaces the session's authentication instead of adding the WEBAUTHN factor to it. The session goes from satisfiedFactors=[PASSWORD] to satisfiedFactors=[WEBAUTHN] — never both — so AllRequiredFactorsAuthorizationManager keeps denying access and the user bounces between the two factor entry points forever. The MFA flow introduced in #272 can never complete.

Reproduction

Found while testing the companion demo app PR (SpringUserFrameworkDemoApp#60) with a Playwright E2E test using Chromium's CDP virtual authenticator:

  1. Enable MFA (user.mfa.enabled=true, factors: PASSWORD, WEBAUTHN)
  2. Log in with username/password → GET /user/mfa/status reports satisfiedFactors=["PASSWORD"], missingFactors=["WEBAUTHN"]
  3. Complete a WebAuthn assertion (POST /login/webauthn)
  4. GET /user/mfa/status now reports satisfiedFactors=["WEBAUTHN"], missingFactors=["PASSWORD"], fullyAuthenticated=false
  5. Any protected request is denied for the missing PASSWORD factor and redirected to passwordEntryPointUri; logging in with the password again drops WEBAUTHN, and so on

Root cause

Spring Security 7's factor accumulation is implemented in AbstractAuthenticationProcessingFilter.doFilter: when its mfaEnabled flag is set and the new authentication has the same name as the current one, the new authentication is rebuilt via toBuilder() with the union of both authorities (this is how FactorGrantedAuthoritys accumulate across logins).

That flag is off by default. It is normally turned on by @EnableMultiFactorAuthentication, whose import (EnableMfaFiltersConfiguration) registers a BeanPostProcessor that calls setMfaEnabled(true) on every AbstractAuthenticationProcessingFilter, AuthenticationFilter, AbstractPreAuthenticatedProcessingFilter, and BasicAuthenticationFilter.

The framework's MfaConfiguration replicates the authorization half of that annotation (it builds the DefaultAuthorizationManagerFactory with AllRequiredFactorsAuthorizationManager from user.mfa.factors), but nothing performs the filter half — mfaEnabled is never set, so authorities are never merged and each login replaces the previous factor.

Suggested fix

When user.mfa.enabled=true, MfaConfiguration should also register the filter post-processor, e.g.:

@Bean
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true")
static BeanPostProcessor mfaFilterEnabler() {
    return new BeanPostProcessor() {
        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) {
            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;
        }
    };
}

(HttpSecurity-built filters go through AutowireBeanFactoryObjectPostProcessor.initializeBean, so a BeanPostProcessor reaches them.)

Note: WebAuthnAuthenticationSuccessHandler is fine as-is — it preserves authentication.getAuthorities(), and the merge happens in the filter before the success handler runs, so the converted token keeps the merged factors.

Workaround

Consuming apps can add the missing half themselves (this is what the demo app now does in MfaSecurityConfig):

@Configuration
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true")
@EnableMultiFactorAuthentication(authorities = {})
public class MfaSecurityConfig { }

The empty authorities attribute skips Spring's AuthorizationManagerFactoryConfiguration (the framework already provides that bean) and only imports the filter post-processor.

Related observation

While debugging this, a second integration gap surfaced: WebSecurityConfig.getUnprotectedURIsList() auto-unprotects /user/mfa/status when MFA is enabled, but not the configured passwordEntryPointUri / webauthnEntryPointUri. If a consuming app forgets to add its WebAuthn entry point page to unprotectedURIs, partially-authenticated users are redirected to a page they are denied access to — an infinite redirect loop (ERR_TOO_MANY_REDIRECTS). Consider auto-adding the configured entry point URIs the same way /user/mfa/status is added. Happy to split this into its own issue if preferred.

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions