Skip to content

Add Multi-factor Authentication Support #17775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

jzheaux
Copy link
Contributor

@jzheaux jzheaux commented Aug 19, 2025

Related to spring-projects/spring-security-samples#351

Implement N authentication factors and they will be required in the order that they are declared:

http
    .authorizeHttpRequests(...)
    .formLogin((form) -> form
        .loginPage("/login/form").permitAll()
        .factor(Customizer.withDefaults()
    )
    .oneTimeTokenLogin((ott) -> ott
        .loginPage("/login/ott").permitAll()
        .factor(Customizer.withDefaults())
    )
    // ...

This will ask for a username/password first and a one-time token second. Thereafter, the user will be considered sufficiently authenticated.

You can also compose with MfaConfigurer:

MyCustomConfigurer<HttpSecurity> custom = new MyCustomConfigurer<>();
MfaConfigurer<HttpSecurity> mfa = new MfaConfigurer("AUTHN_CUSTOM", custom);

// ...

http
    .authorizeHttpRequests(...)
    .formLogin((form) -> form.factor(Customizer.withDefaults())
    .with(custom, Customizer.withDefaults())
    .with(mfa, (mfa) -> mfa
        .authenticationEntryPoint("/authorize")
    )

You can grant a custom set of authorities:

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/profille").hasAuthority("profile:read")
        .anyRequest().authenticated()
    )
    .formLogin((form) -> form
        .loginPage("/login/form").permitAll()
        .factor((factor) -> factor
            .grants(Duration.ofMinutes(5), "profile:read")
        )
    .oneTimeTokenLogin((ott) -> ott
       .loginPage("/login/ott").permitAll()
        .factor(Customizer.withDefaults())
    )
    // ...

In this case, the user has the profle:read authority for five minutes after having given their username and password. After five minutes, if they navigate to /profile again, they will need to re-authenticate.

All factors registered are required by default. However, more complex arrangements are possible:

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/login/ott").access(hasAuthority("AUTHN_X509"))
        .anyRequest().access(hasAuthority("app"))
    )
    .x509((x509) -> x509
        .factor(Customizer.withDefaults())
    )
    .oneTimeTokenLogin((ott) -> ott
        .loginPage("/login/ott").permitAll()
        .factor((factor) -> factor.grants("app"))
    )
    .oauth2ResourceServer((oauth2) -> oauth2
        .jwt(Customizer.withDefaults())
        .factor((factor) -> factor.grants("app"))
    )

The above arrangement states that either Bearer Token authentication, or X.509 + OTT is sufficient to use the app. It further requires that X.509 must pass before the one-time token can be entered.

Finally, you can completely customize how a set of authorities is obtained by registering an AuthorizationEntryPoint:

@Component
public class MissingScopeAuthorizationEntryPoint implements AuthorizationEntryPoint {
    @Override 
    public boolean commence(HttpServletRequest request, HttpServletResponse response, AuthorizationRequest authz) {
        Set<String> authorities = AuthorityUtils.authoritiesListToSet(authz.getAuthorities());
        if (!authorities.contains("SCOPE_https://www.gmail.com")) {
            return false;
        }
        // formulate /authorize request to Google and redirect
    }
}

// ...

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/email/stats").hasAuthority("SCOPE_https://www.gmail.com")
        .anyRequest().authenticated()
    )
    .oauth2Login((oauth2) -> oauth2.factor(Customizer.withDefaults())
    .exceptionHandling((exceptions) -> exceptions
        .authorizationEntryPoint((a) -> a.addAll(missingScopeAuthorizationEntryPoint))
    )

jzheaux added 15 commits August 12, 2025 17:10
Oftentimes, a filter has its own authentication manager or it
has something specific that it needs to do regarding authentication
that is independent of a shared authentication manager.

Allowing the authentication manager to be post-processed allows
an application to apply authentication-mechanism-specific
post-processing to the authentication request and result.
There are a number of scenarios where it's desireable to update the
authorities in an authentication after identity has already been established.

For example, if a second factor is required or if temporary
authorization is needed for a given page, these likely won't
update the principal; they simply need to add more authorities
to the existing authentication.
This is a handy implementation that allows an entry point to
operate differently when there is already a known user in
context. In some cases, it is not desireable to show the
end user another form and ask them for their username when
we already know it, for example.
When access is denied, if we have a way to obtain the missing
authorities, this class allows that way to be specified.
This update allows AuthoritiesAuthorizationManager to operate
in either and or or mode, given a list of authorities.
A configurer that extends the ability of any authentication configurer
to participate as an additional authentication factor
Allowing individual authorities to expire offers enormous flexibility
as far as granting authorities that need to be renewed independently
from logging in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant