Skip to content

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((authorize) -> authorize.anyRequest()
        .access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT")))
    )
    .formLogin(Customizer.withDefaults())
    .oneTimeTokenLogin(Customizer.withDefaults())
    // ...

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

Note that you can also publish an AuthorizationManagerFactory<Object> bean that checks for FACTOR_PASSWORD and FACTOR_OTT; however, this has not been added to this PR.

You can also specify a custom action to perform when a given factor is missing:

http
    .authorizeHttpRequests((authorize) -> authorize.anyRequest()
        .access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_WEBAUTHN")))
    )
    .formLogin(Customizer.withDefaults())
    .webauthn((webauthn) -> ...)
    .exceptionHandling((exceptions) -> exceptions
        .defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/webauthn"), "FACTOR_WEBAUTHN")
    )
    // ...

Note that authentication factors already integrate with defaultAuthenticationEntryPointFor in this PR. The above is needed for WebAuthn since it doesn't expose a custom entry point page in its DSL.

Copy link
Member

@rwinch rwinch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR @jzheaux! I've provided feedback inline.

@@ -263,4 +336,128 @@ private RequestCache getRequestCache(H http) {
return new HttpSessionRequestCache();
}

private static final class AuthenticationFactorDelegatingAuthenticationEntryPoint
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we can delete AuthenticationFactorDelegatingAuthenticationEntryPoint because we don't need to control the log in page based upon the factors.

@@ -225,4 +235,28 @@ private <C> C getSharedOrBean(H http, Class<C> type) {
return context.getBeanProvider(type).getIfUnique();
}

private static final class AuthorityGrantingAuthenticationProvider implements AuthenticationProvider {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be removed in favor of PreAuthenticatedAuthenticationProvider adding the additional factor? If the factor needs to vary, perhaps allow injecting it into PreAuthenticatedAuthenticationProvider.

@@ -165,6 +185,26 @@ public ExceptionHandlingConfigurer<H> defaultAuthenticationEntryPointFor(Authent
return this;
}

public ExceptionHandlingConfigurer<H> defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this method. It is only used once and the value of preferredMatcher is AnyRequestMatcher. If users need an AuthenticationEntryPoint that can delegate by RequestMatcher, then they can inject DelegatingAuthenticationEntryPoint.

This should also allow changing the entryPoints member variable to be of type Map<String, AuthenticationEntryPoint>

@@ -75,6 +93,8 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>

private LinkedHashMap<RequestMatcher, AccessDeniedHandler> defaultDeniedHandlerMappings = new LinkedHashMap<>();

private Map<String, LinkedHashMap<RequestMatcher, AuthenticationEntryPoint>> entryPoints = new LinkedHashMap<>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try renaming this to something that helps describe what it does. Something like missingAuthorityToEntryPoint makes it clear what the key is.

NOTE: They type can be simplified to Map<String, AuthenticationEntryPoint> as described in this comment.

@@ -391,4 +410,52 @@ public ApplicationContext getContext() {
return this.context;
}

private static final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a CSRF attack that could lead to leaking the OTT through social engineering. Consider a user who is authenticated, but who has not performed MFA with OTT. They then go to evil.example.com which performs a request to a resource that requires OTT. This will automatically submit a token to the user which evil.example.com could say we sent you a OTT please paste it in this form on evil.example.com

What's more is there is no controls in place for how many OTT are created and submitted. I think that we want a confirmation page that looks much like the login page, but does not allow the username to be changed.

}

private Collection<GrantedAuthority> authorizationRequest(Exception ex) {
Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is costly. If we keep the ThrowableAnalyzer logic in this class, we should do an instanceof check on the ex argument first. Perhaps we should have a way for this to be built into ThrowableAnalyzer if it isn't there already.


}

private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be extracted into a class in spring-security-web. I believe it makes sense as a sibling of AccessDeniedHandler, but change for tangles due to authentication APIs in here (access historically has had AuthenticationManager in it though so I think this works).

UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken
.authenticated(principal, authentication.getCredentials(),
this.authoritiesMapper.mapAuthorities(user.getAuthorities()))
.toBuilder()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid creating twoUsernamePasswordAuthentiationToken instances?

this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken
.authenticated(user, password, this.authoritiesMapper.mapAuthorities(user.getAuthorities()))
.toBuilder()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid creating two instances of UsernamePasswordAuthenticationToken?

return this.defaults;
}

private Collection<GrantedAuthority> authorizationRequest(Exception ex) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to something like missingAuthorities

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.

2 participants