Skip to content

feat: Wrap Spring Security 7 MFA in simple user.mfa.* properties #268

@devondragon

Description

@devondragon

Summary

Wrap Spring Security 7's built-in Multi-Factor Authentication infrastructure in simple user.mfa.* configuration properties, consistent with how we wrap passkeys via user.webauthn.*.

A consuming app would enable MFA with:

user.mfa.enabled: true
user.mfa.factors: PASSWORD, WEBAUTHN

This makes ALL authenticated endpoints require all configured factors. Spring Security 7 handles the enforcement, redirection between factor login pages, and session management automatically.

Background & Research

Spring Security 7 (shipped with Spring Boot 4, which we already target) has built-in MFA via:

  • FactorGrantedAuthority — automatically added to Authentication on each successful auth step
  • FactorGrantedAuthority.WEBAUTHN_AUTHORITY — predefined constant for passkeys (our WebAuthnAuthenticationProvider already adds this)
  • FactorGrantedAuthority.PASSWORD_AUTHORITY — for password login
  • @EnableMultiFactorAuthentication — annotation to globally require multiple factors
  • AuthorizationManagerFactories.multiFactor() — programmatic per-endpoint factor requirements
  • DelegatingMissingAuthorityAccessDeniedHandler — redirects partially-authenticated users to the correct factor's login page

Current State

Our WebAuthnAuthenticationSuccessHandler already preserves FactorGrantedAuthority.WEBAUTHN_AUTHORITY through the principal conversion. The MFA infrastructure essentially works today — a consuming app could manually use @EnableMultiFactorAuthentication with our library. This issue is about making it easy.

Compliance Context

  • PCI DSS 4.x (FAQ 1596): Passkeys satisfy MFA alone if factors are separate and secure, but many auditors prefer explicit two-step auth
  • NIST SP 800-63-4: Synced passkeys can achieve AAL2; AAL2 must offer phishing-resistant option
  • Industry: Keycloak, Auth0, Okta all support passkeys as both password replacement AND MFA — configurable per-deployment

Implementation Plan

New Files (~6)

  1. MfaConfigProperties.java@ConfigurationProperties(prefix = "user.mfa")

    • boolean enabled (default: false)
    • List<String> factors (e.g., PASSWORD, WEBAUTHN)
    • String passwordEntryPointUri — redirect when PASSWORD factor missing
    • String webauthnEntryPointUri — redirect when WEBAUTHN factor missing
  2. MfaConfiguration.java@Configuration + @ConditionalOnProperty(name = "user.mfa.enabled")

    • Programmatically replicates @EnableMultiFactorAuthentication (which needs compile-time constants)
    • Bean: DefaultAuthorizationManagerFactory<?> via AuthorizationManagerFactories.multiFactor().requireFactors(...) — makes .authenticated() require all factors
    • Bean: BeanPostProcessor that calls setMfaEnabled(true) on all auth filters
    • Static helper: mapFactorToAuthority("PASSWORD")FactorGrantedAuthority.PASSWORD_AUTHORITY
    • Startup validation: error if factors empty, error if WEBAUTHN in factors but webauthn disabled, warning re: passwordless accounts + PASSWORD factor
  3. MfaStatusResponse.java — DTO: mfaEnabled, requiredFactors, satisfiedFactors, missingFactors, fullyAuthenticated

  4. MfaAPI.java@RestController at /user/mfa

    • GET /user/mfa/status — returns which factors are required/satisfied/missing for current session
    • Must be accessible to partially-authenticated users (added to unprotected URIs)

Modified Files (~4)

  1. WebSecurityConfig.java

    • Inject ObjectProvider<MfaConfigProperties>
    • Add setupMfa() — configures DelegatingMissingAuthorityAccessDeniedHandler
    • Update getUnprotectedURIsList() — add /user/mfa/** when MFA enabled
  2. dsspringuserconfig.properties — add MFA defaults

  3. WebAuthnAuthenticationSuccessHandlerTest.java — add test verifying FactorGrantedAuthority.WEBAUTHN_AUTHORITY preservation

  4. New test filesMfaConfigurationTest, MfaAPITest, MfaSecurityTest

Edge Cases

  • Passwordless accounts + PASSWORD factor: Users with passkey-only accounts can't satisfy PASSWORD. Startup warning + documentation.
  • OAuth2 + MFA coexistence: OAuth2 uses authenticationEntryPoint (unauthenticated). MFA uses accessDeniedHandler (partially-authenticated). No conflict.
  • WebAuthn endpoints during MFA flow: Filters process before authorization — no URI exemptions needed.

Out of Scope (Future)

  • Per-URI step-up authentication (user.mfa.protectedURIs)
  • Factor validity duration (user.mfa.factorValidity: 30m)
  • MFA challenge UI pages (see companion issue in DemoApp repo)

Related

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