From 179f7f80887cbacd92a8d93964c2b5763911cfed Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:10:26 -0600 Subject: [PATCH 01/12] Support AuthenticationManager Post-Processing 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. --- .../configurers/AbstractAuthenticationFilterConfigurer.java | 2 +- .../annotation/web/configurers/HttpBasicConfigurer.java | 2 +- .../config/annotation/web/configurers/WebAuthnConfigurer.java | 4 +++- .../config/annotation/web/configurers/X509Configurer.java | 3 ++- .../server/resource/OAuth2ResourceServerConfigurer.java | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractAuthenticationFilterConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractAuthenticationFilterConfigurer.java index 07d43cbc9e..34d83d8b7f 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractAuthenticationFilterConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractAuthenticationFilterConfigurer.java @@ -282,7 +282,7 @@ public void configure(B http) throws Exception { if (requestCache != null) { this.defaultSuccessHandler.setRequestCache(requestCache); } - this.authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); + this.authFilter.setAuthenticationManager(postProcess(http.getSharedObject(AuthenticationManager.class))); this.authFilter.setAuthenticationSuccessHandler(this.successHandler); this.authFilter.setAuthenticationFailureHandler(this.failureHandler); if (this.authenticationDetailsSource != null) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java index ad03ae6052..724bab19a7 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java @@ -207,7 +207,7 @@ private void registerDefaultLogoutSuccessHandler(B http, RequestMatcher preferre @Override public void configure(B http) { - AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); + AuthenticationManager authenticationManager = postProcess(http.getSharedObject(AuthenticationManager.class)); BasicAuthenticationFilter basicAuthenticationFilter = new BasicAuthenticationFilter(authenticationManager, this.authenticationEntryPoint); if (this.authenticationDetailsSource != null) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java index 7ec3279efb..671c03c303 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java @@ -24,6 +24,7 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.core.userdetails.UserDetailsService; @@ -162,8 +163,9 @@ public void configure(H http) throws Exception { WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(userEntities, userCredentials); PublicKeyCredentialCreationOptionsRepository creationOptionsRepository = creationOptionsRepository(); WebAuthnAuthenticationFilter webAuthnAuthnFilter = new WebAuthnAuthenticationFilter(); - webAuthnAuthnFilter.setAuthenticationManager( + AuthenticationManager authenticationManager = postProcess( new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService))); + webAuthnAuthnFilter.setAuthenticationManager(authenticationManager); WebAuthnRegistrationFilter webAuthnRegistrationFilter = new WebAuthnRegistrationFilter(userCredentials, rpOperations); PublicKeyCredentialCreationOptionsFilter creationOptionsFilter = new PublicKeyCredentialCreationOptionsFilter( diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java index 6966d3e156..ad52680081 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java @@ -183,7 +183,8 @@ public void init(H http) { @Override public void configure(H http) { - X509AuthenticationFilter filter = getFilter(http.getSharedObject(AuthenticationManager.class), http); + AuthenticationManager authenticationManager = postProcess(http.getSharedObject(AuthenticationManager.class)); + X509AuthenticationFilter filter = getFilter(authenticationManager, http); http.addFilter(filter); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index 331730f1db..f2591c7343 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -265,7 +265,7 @@ public void init(H http) { public void configure(H http) { AuthenticationManagerResolver resolver = this.authenticationManagerResolver; if (resolver == null) { - AuthenticationManager authenticationManager = getAuthenticationManager(http); + AuthenticationManager authenticationManager = postProcess(getAuthenticationManager(http)); resolver = (request) -> authenticationManager; } From b235c4548b4c87ce02152aa4e6f1cbb0c0bd3a85 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:17:12 -0600 Subject: [PATCH 02/12] Support Updating Authorities 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. --- .../CasAuthenticationToken.java | 9 +++- .../RememberMeAuthenticationToken.java | 10 ++++- .../TestingAuthenticationToken.java | 10 ++++- .../UsernamePasswordAuthenticationToken.java | 9 +++- .../jaas/JaasAuthenticationToken.java | 10 +++++ .../ott/OneTimeTokenAuthenticationToken.java | 12 ++++- .../security/core/AuthenticationResult.java | 44 +++++++++++++++++++ .../OAuth2AuthenticationToken.java | 8 +++- .../BearerTokenAuthentication.java | 9 +++- .../JwtAuthenticationToken.java | 9 +++- .../Saml2AssertionAuthentication.java | 6 +++ .../authentication/Saml2Authentication.java | 8 +++- .../PreAuthenticatedAuthenticationToken.java | 12 ++++- .../WebAuthnAuthentication.java | 8 +++- 14 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/org/springframework/security/core/AuthenticationResult.java diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationToken.java b/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationToken.java index e19f8bd33c..3f026724fb 100644 --- a/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationToken.java +++ b/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationToken.java @@ -22,6 +22,7 @@ import org.apereo.cas.client.validation.Assertion; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.util.Assert; @@ -33,7 +34,7 @@ * @author Ben Alex * @author Scott Battaglia */ -public class CasAuthenticationToken extends AbstractAuthenticationToken implements Serializable { +public class CasAuthenticationToken extends AbstractAuthenticationToken implements Serializable, AuthenticationResult { private static final long serialVersionUID = 620L; @@ -104,6 +105,12 @@ private CasAuthenticationToken(final Integer keyHash, final Object principal, fi setAuthenticated(true); } + @Override + public CasAuthenticationToken withGrantedAuthorities(Collection authorities) { + return new CasAuthenticationToken(this.keyHash, getPrincipal(), getCredentials(), authorities, this.userDetails, + this.assertion); + } + private static Integer extractKeyHash(String key) { Assert.hasLength(key, "key cannot be null or empty"); return key.hashCode(); diff --git a/core/src/main/java/org/springframework/security/authentication/RememberMeAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/RememberMeAuthenticationToken.java index 5c17618cef..f94c6d8cb0 100644 --- a/core/src/main/java/org/springframework/security/authentication/RememberMeAuthenticationToken.java +++ b/core/src/main/java/org/springframework/security/authentication/RememberMeAuthenticationToken.java @@ -18,7 +18,9 @@ import java.util.Collection; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; /** * Represents a remembered Authentication. @@ -29,7 +31,7 @@ * @author Ben Alex * @author Luke Taylor */ -public class RememberMeAuthenticationToken extends AbstractAuthenticationToken { +public class RememberMeAuthenticationToken extends AbstractAuthenticationToken implements AuthenticationResult { private static final long serialVersionUID = 620L; @@ -70,6 +72,12 @@ private RememberMeAuthenticationToken(Integer keyHash, Object principal, setAuthenticated(true); } + @Override + public RememberMeAuthenticationToken withGrantedAuthorities(Collection authorities) { + Assert.isTrue(isAuthenticated(), "cannot grant authorities to unauthenticated tokens"); + return new RememberMeAuthenticationToken(this.keyHash, getPrincipal(), authorities); + } + /** * Always returns an empty String * @return an empty String diff --git a/core/src/main/java/org/springframework/security/authentication/TestingAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/TestingAuthenticationToken.java index abfc6560f4..65ad4d078c 100644 --- a/core/src/main/java/org/springframework/security/authentication/TestingAuthenticationToken.java +++ b/core/src/main/java/org/springframework/security/authentication/TestingAuthenticationToken.java @@ -19,8 +19,10 @@ import java.util.Collection; import java.util.List; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.util.Assert; /** * An {@link org.springframework.security.core.Authentication} implementation that is @@ -30,7 +32,7 @@ * * @author Ben Alex */ -public class TestingAuthenticationToken extends AbstractAuthenticationToken { +public class TestingAuthenticationToken extends AbstractAuthenticationToken implements AuthenticationResult { private static final long serialVersionUID = 1L; @@ -61,6 +63,12 @@ public TestingAuthenticationToken(Object principal, Object credentials, setAuthenticated(true); } + @Override + public TestingAuthenticationToken withGrantedAuthorities(Collection authorities) { + Assert.isTrue(isAuthenticated(), "cannot grant authorities to unauthenticated tokens"); + return new TestingAuthenticationToken(getPrincipal(), this.credentials, authorities); + } + @Override public Object getCredentials() { return this.credentials; diff --git a/core/src/main/java/org/springframework/security/authentication/UsernamePasswordAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/UsernamePasswordAuthenticationToken.java index 2ec7d269bf..aaa8e28454 100644 --- a/core/src/main/java/org/springframework/security/authentication/UsernamePasswordAuthenticationToken.java +++ b/core/src/main/java/org/springframework/security/authentication/UsernamePasswordAuthenticationToken.java @@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.util.Assert; @@ -35,7 +36,7 @@ * @author Ben Alex * @author Norbert Nowak */ -public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken { +public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken implements AuthenticationResult { private static final long serialVersionUID = 620L; @@ -100,6 +101,12 @@ public static UsernamePasswordAuthenticationToken authenticated(Object principal return new UsernamePasswordAuthenticationToken(principal, credentials, authorities); } + @Override + public UsernamePasswordAuthenticationToken withGrantedAuthorities(Collection authorities) { + Assert.isTrue(isAuthenticated(), "cannot grant authorities to unauthenticated tokens"); + return new UsernamePasswordAuthenticationToken(getPrincipal(), getCredentials(), authorities); + } + @Override public @Nullable Object getCredentials() { return this.credentials; diff --git a/core/src/main/java/org/springframework/security/authentication/jaas/JaasAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/jaas/JaasAuthenticationToken.java index 314f79e563..7f615956f1 100644 --- a/core/src/main/java/org/springframework/security/authentication/jaas/JaasAuthenticationToken.java +++ b/core/src/main/java/org/springframework/security/authentication/jaas/JaasAuthenticationToken.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.jaas; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import javax.security.auth.login.LoginContext; @@ -24,6 +26,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; /** * UsernamePasswordAuthenticationToken extension to carry the Jaas LoginContext that the @@ -52,4 +55,11 @@ public LoginContext getLoginContext() { return this.loginContext; } + @Override + public JaasAuthenticationToken withGrantedAuthorities(Collection authorities) { + Assert.isTrue(isAuthenticated(), "cannot grant authorities to unauthenticated tokens"); + return new JaasAuthenticationToken(getPrincipal(), getCredentials(), new ArrayList<>(authorities), + this.loginContext); + } + } diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java index b956a3771a..5c5f40ff27 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java @@ -19,11 +19,14 @@ import java.io.Serial; import java.util.Collection; import java.util.Collections; +import java.util.Objects; import org.jspecify.annotations.Nullable; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; /** * Represents a One-Time Token authentication that can be authenticated or not. @@ -31,7 +34,7 @@ * @author Marcus da Coregio * @since 6.4 */ -public class OneTimeTokenAuthenticationToken extends AbstractAuthenticationToken { +public class OneTimeTokenAuthenticationToken extends AbstractAuthenticationToken implements AuthenticationResult { @Serial private static final long serialVersionUID = -8691636031126328365L; @@ -56,6 +59,13 @@ public OneTimeTokenAuthenticationToken(Object principal, Collection authorities) { + Assert.isTrue(isAuthenticated(), "cannot grant authorities to unauthenticated tokens"); + Object principal = Objects.requireNonNull(this.principal); + return OneTimeTokenAuthenticationToken.authenticated(principal, authorities); + } + /** * Creates an unauthenticated token * @param tokenValue the one-time token value diff --git a/core/src/main/java/org/springframework/security/core/AuthenticationResult.java b/core/src/main/java/org/springframework/security/core/AuthenticationResult.java new file mode 100644 index 0000000000..25fb836086 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/AuthenticationResult.java @@ -0,0 +1,44 @@ +/* + * 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.core; + +import java.io.Serial; +import java.util.Collection; +import java.util.HashSet; +import java.util.function.Consumer; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface AuthenticationResult extends Authentication { + + @Serial + long serialVersionUID = -525010730472051621L; + + default AuthenticationResult withGrantedAuthorities(Consumer> consumer) { + Collection existing = new HashSet<>(getAuthorities()); + consumer.accept(existing); + return withGrantedAuthorities(existing); + } + + AuthenticationResult withGrantedAuthorities(Collection authorities); + + default boolean isAuthenticated() { + return true; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/authentication/OAuth2AuthenticationToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/authentication/OAuth2AuthenticationToken.java index c766522199..c8499df806 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/authentication/OAuth2AuthenticationToken.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/authentication/OAuth2AuthenticationToken.java @@ -20,6 +20,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -40,7 +41,7 @@ * @see OAuth2User * @see OAuth2AuthorizedClient */ -public class OAuth2AuthenticationToken extends AbstractAuthenticationToken { +public class OAuth2AuthenticationToken extends AbstractAuthenticationToken implements AuthenticationResult { private static final long serialVersionUID = 620L; @@ -65,6 +66,11 @@ public OAuth2AuthenticationToken(OAuth2User principal, Collection authorities) { + return new OAuth2AuthenticationToken(getPrincipal(), authorities, this.authorizedClientRegistrationId); + } + @Override public OAuth2User getPrincipal() { return this.principal; diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthentication.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthentication.java index f3dfb83270..6fbf0b7ccd 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthentication.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthentication.java @@ -21,6 +21,7 @@ import java.util.LinkedHashMap; import java.util.Map; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.Transient; import org.springframework.security.oauth2.core.OAuth2AccessToken; @@ -35,7 +36,8 @@ * @since 5.2 */ @Transient -public class BearerTokenAuthentication extends AbstractOAuth2TokenAuthenticationToken { +public class BearerTokenAuthentication extends AbstractOAuth2TokenAuthenticationToken + implements AuthenticationResult { private static final long serialVersionUID = 620L; @@ -56,6 +58,11 @@ public BearerTokenAuthentication(OAuth2AuthenticatedPrincipal principal, OAuth2A setAuthenticated(true); } + @Override + public BearerTokenAuthentication withGrantedAuthorities(Collection authorities) { + return new BearerTokenAuthentication((OAuth2AuthenticatedPrincipal) getPrincipal(), getToken(), authorities); + } + @Override public Map getTokenAttributes() { return this.attributes; diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationToken.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationToken.java index 43cc749d9d..ca17358491 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationToken.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationToken.java @@ -19,6 +19,7 @@ import java.util.Collection; import java.util.Map; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.Transient; import org.springframework.security.oauth2.jwt.Jwt; @@ -33,7 +34,8 @@ * @see Jwt */ @Transient -public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken { +public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken + implements AuthenticationResult { private static final long serialVersionUID = 620L; @@ -71,6 +73,11 @@ public JwtAuthenticationToken(Jwt jwt, Collection au this.name = name; } + @Override + public JwtAuthenticationToken withGrantedAuthorities(Collection authorities) { + return new JwtAuthenticationToken(getToken(), authorities, this.name); + } + @Override public Map getTokenAttributes() { return this.getToken().getClaims(); diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AssertionAuthentication.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AssertionAuthentication.java index 3b528c04a3..9efdbeb2fd 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AssertionAuthentication.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AssertionAuthentication.java @@ -53,6 +53,12 @@ public Saml2AssertionAuthentication(Object principal, Saml2ResponseAssertionAcce setAuthenticated(true); } + @Override + public Saml2AssertionAuthentication withGrantedAuthorities(Collection authorities) { + return new Saml2AssertionAuthentication(getPrincipal(), getCredentials(), authorities, + this.relyingPartyRegistrationId); + } + @Override public Saml2ResponseAssertionAccessor getCredentials() { return this.assertion; diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java index 82b4042c49..fdd62e4b27 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java @@ -22,6 +22,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.AuthenticatedPrincipal; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.util.Assert; @@ -36,7 +37,7 @@ * @since 5.2 * @see AbstractAuthenticationToken */ -public class Saml2Authentication extends AbstractAuthenticationToken { +public class Saml2Authentication extends AbstractAuthenticationToken implements AuthenticationResult { @Serial private static final long serialVersionUID = 405897702378720477L; @@ -69,6 +70,11 @@ public Saml2Authentication(Object principal, String saml2Response, setAuthenticated(true); } + @Override + public Saml2Authentication withGrantedAuthorities(Collection authorities) { + return new Saml2Authentication(getPrincipal(), getSaml2Response(), authorities); + } + @Override public Object getPrincipal() { return this.principal; diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedAuthenticationToken.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedAuthenticationToken.java index dc1a15e89e..2e6997679e 100755 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedAuthenticationToken.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedAuthenticationToken.java @@ -17,9 +17,12 @@ package org.springframework.security.web.authentication.preauth; import java.util.Collection; +import java.util.Objects; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; /** * {@link org.springframework.security.core.Authentication} implementation for @@ -28,7 +31,7 @@ * @author Ruud Senden * @since 2.0 */ -public class PreAuthenticatedAuthenticationToken extends AbstractAuthenticationToken { +public class PreAuthenticatedAuthenticationToken extends AbstractAuthenticationToken implements AuthenticationResult { private static final long serialVersionUID = 620L; @@ -64,6 +67,13 @@ public PreAuthenticatedAuthenticationToken(Object aPrincipal, Object aCredential setAuthenticated(true); } + @Override + public PreAuthenticatedAuthenticationToken withGrantedAuthorities(Collection authorities) { + Assert.isTrue(isAuthenticated(), "cannot grant authorities to unauthenticated tokens"); + Object principal = Objects.requireNonNull(getPrincipal()); + return new PreAuthenticatedAuthenticationToken(principal, getCredentials(), authorities); + } + /** * Get the credentials */ diff --git a/webauthn/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthentication.java b/webauthn/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthentication.java index c44e648402..8726d75fae 100644 --- a/webauthn/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthentication.java +++ b/webauthn/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthentication.java @@ -20,6 +20,7 @@ import java.util.Collection; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; import org.springframework.util.Assert; @@ -32,7 +33,7 @@ * @since 6.4 * @see WebAuthnAuthenticationRequestToken */ -public class WebAuthnAuthentication extends AbstractAuthenticationToken { +public class WebAuthnAuthentication extends AbstractAuthenticationToken implements AuthenticationResult { @Serial private static final long serialVersionUID = -4879907158750659197L; @@ -46,6 +47,11 @@ public WebAuthnAuthentication(PublicKeyCredentialUserEntity principal, super.setAuthenticated(true); } + @Override + public WebAuthnAuthentication withGrantedAuthorities(Collection authorities) { + return new WebAuthnAuthentication(this.principal, authorities); + } + @Override public void setAuthenticated(boolean authenticated) { Assert.isTrue(!authenticated, "Cannot set this token to trusted"); From 8273772c18ad53fefc50989bd7b76c8ee8b4f99c Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:19:04 -0600 Subject: [PATCH 03/12] Add PostAuthenticationEntryPoint 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. --- .../PostAuthenticationEntryPoint.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 web/src/main/java/org/springframework/security/web/authentication/PostAuthenticationEntryPoint.java diff --git a/web/src/main/java/org/springframework/security/web/authentication/PostAuthenticationEntryPoint.java b/web/src/main/java/org/springframework/security/web/authentication/PostAuthenticationEntryPoint.java new file mode 100644 index 0000000000..71f5b08798 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/PostAuthenticationEntryPoint.java @@ -0,0 +1,84 @@ +/* + * 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.web.authentication; + +import java.io.IOException; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.FormPostRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; + +public final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final String entryPointUri; + + private final Map> params; + + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + + private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy(); + + public PostAuthenticationEntryPoint(String entryPointUri, Map> params) { + this.entryPointUri = entryPointUri; + this.params = params; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + Authentication authentication = getAuthentication(authException); + Assert.notNull(authentication, "could not find authentication in order to perform post"); + Map params = this.params.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, (entry) -> entry.getValue().apply(authentication))); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.entryPointUri); + CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrf != null) { + builder.queryParam(csrf.getParameterName(), csrf.getToken()); + } + String entryPointUrl = builder.build(false).expand(params).toUriString(); + this.redirectStrategy.sendRedirect(request, response, entryPointUrl); + } + + private Authentication getAuthentication(AuthenticationException authException) { + Authentication authentication = authException.getAuthenticationRequest(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication; + } + authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication; + } + return null; + } + +} From dab32cbc88b36275586112358dd62ded07fa12a3 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:21:09 -0600 Subject: [PATCH 04/12] Add Authorization-Requesting AccessDeniedHandler When access is denied, if we have a way to obtain the missing authorities, this class allows that way to be specified. --- .../AuthorityAuthorizationDecision.java | 3 +- .../authorization/AuthorizationRequest.java | 27 +++++++ .../security/web/AuthorizationEntryPoint.java | 25 ++++++ ...rizationRequestingAccessDeniedHandler.java | 76 +++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/springframework/security/authorization/AuthorizationRequest.java create mode 100644 web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java create mode 100644 web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorityAuthorizationDecision.java b/core/src/main/java/org/springframework/security/authorization/AuthorityAuthorizationDecision.java index d5f461caec..efbdb4fd81 100644 --- a/core/src/main/java/org/springframework/security/authorization/AuthorityAuthorizationDecision.java +++ b/core/src/main/java/org/springframework/security/authorization/AuthorityAuthorizationDecision.java @@ -27,7 +27,7 @@ * @author Marcus Da Coregio * @since 5.6 */ -public class AuthorityAuthorizationDecision extends AuthorizationDecision { +public class AuthorityAuthorizationDecision extends AuthorizationDecision implements AuthorizationRequest { @Serial private static final long serialVersionUID = -8338309042331376592L; @@ -39,6 +39,7 @@ public AuthorityAuthorizationDecision(boolean granted, Collection getAuthorities() { return this.authorities; } diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationRequest.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationRequest.java new file mode 100644 index 0000000000..82f80cf96f --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationRequest.java @@ -0,0 +1,27 @@ +/* + * 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.authorization; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; + +public interface AuthorizationRequest { + + Collection getAuthorities(); + +} diff --git a/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java b/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java new file mode 100644 index 0000000000..2886dd9d6d --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java @@ -0,0 +1,25 @@ +/* + * 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.web; + +import org.springframework.security.authorization.AuthorizationRequest; + +public interface AuthorizationEntryPoint extends AuthenticationEntryPoint { + + boolean authorizes(AuthorizationRequest authorizationRequest); + +} diff --git a/web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java b/web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java new file mode 100644 index 0000000000..bd722839bb --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java @@ -0,0 +1,76 @@ +/* + * 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.web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.AuthorizationRequest; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.AccessDeniedHandlerImpl; + +public final class AuthorizationRequestingAccessDeniedHandler implements AccessDeniedHandler { + + private final List entries; + + private final AccessDeniedHandler delegate = new AccessDeniedHandlerImpl(); + + public AuthorizationRequestingAccessDeniedHandler(List entries) { + this.entries = new ArrayList<>(entries); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException access) + throws IOException, ServletException { + AuthorizationRequest authorizationRequest = authorizationRequest(access); + if (authorizationRequest == null) { + this.delegate.handle(request, response, access); + return; + } + for (AuthorizationEntryPoint entry : this.entries) { + if (entry.authorizes(authorizationRequest)) { + AuthenticationException iae = new InsufficientAuthenticationException("access denied", access); + entry.commence(request, response, iae); + return; + } + } + this.delegate.handle(request, response, access); + } + + private AuthorizationRequest authorizationRequest(AccessDeniedException access) { + if (access instanceof AuthorizationRequest request) { + return request; + } + if (!(access instanceof AuthorizationDeniedException denied)) { + return null; + } + if (!(denied.getAuthorizationResult() instanceof AuthorizationRequest request)) { + return null; + } + return request; + } + +} From 574318bae026c2629e712abd7d9150f2172ca671 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:22:49 -0600 Subject: [PATCH 05/12] Support Requiring All Authorities This update allows AuthoritiesAuthorizationManager to operate in either and or or mode, given a list of authorities. --- .../AuthoritiesAuthorizationManager.java | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java index c70d67c332..1eb59488e9 100644 --- a/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java @@ -17,6 +17,9 @@ package org.springframework.security.authorization; import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.function.Supplier; import org.springframework.security.access.hierarchicalroles.NullRoleHierarchy; @@ -37,6 +40,22 @@ public final class AuthoritiesAuthorizationManager implements AuthorizationManag private RoleHierarchy roleHierarchy = new NullRoleHierarchy(); + private boolean hasAnyAuthority = true; + + public AuthoritiesAuthorizationManager() { + + } + + public static AuthoritiesAuthorizationManager hasAnyAuthority() { + return new AuthoritiesAuthorizationManager(); + } + + public static AuthoritiesAuthorizationManager hasAllAuthorities() { + AuthoritiesAuthorizationManager manager = new AuthoritiesAuthorizationManager(); + manager.hasAnyAuthority = false; + return manager; + } + /** * Sets the {@link RoleHierarchy} to be used. Default is {@link NullRoleHierarchy}. * Cannot be null. @@ -56,25 +75,25 @@ public void setRoleHierarchy(RoleHierarchy roleHierarchy) { */ @Override public AuthorizationResult authorize(Supplier authentication, Collection authorities) { - boolean granted = isGranted(authentication.get(), authorities); - return new AuthorityAuthorizationDecision(granted, AuthorityUtils.createAuthorityList(authorities)); - } - - private boolean isGranted(Authentication authentication, Collection authorities) { - return authentication != null && isAuthorized(authentication, authorities); - } - - private boolean isAuthorized(Authentication authentication, Collection authorities) { - for (GrantedAuthority grantedAuthority : getGrantedAuthorities(authentication)) { - if (authorities.contains(grantedAuthority.getAuthority())) { - return true; - } + Set needed = new HashSet<>(authorities); + for (GrantedAuthority authority : getGrantedAuthorities(authentication.get())) { + needed.remove(authority.getAuthority()); + } + if (this.hasAnyAuthority) { + boolean granted = needed.size() < authorities.size(); + return new AuthorityAuthorizationDecision(granted, AuthorityUtils.createAuthorityList(authorities)); + } + else { + boolean granted = needed.isEmpty(); + return new AuthorityAuthorizationDecision(granted, AuthorityUtils.createAuthorityList(needed)); } - return false; } - private Collection getGrantedAuthorities(Authentication authentication) { - return this.roleHierarchy.getReachableGrantedAuthorities(authentication.getAuthorities()); + private Collection getGrantedAuthorities(Authentication authentication) { + if (authentication == null) { + return List.of(); + } + return new HashSet<>(this.roleHierarchy.getReachableGrantedAuthorities(authentication.getAuthorities())); } } From 902740958f94ec4cdbea1f11671a86c82be25288 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:25:40 -0600 Subject: [PATCH 06/12] Add MfaConfigurer A configurer that extends the ability of any authentication configurer to participate as an additional authentication factor --- .../AuthorizeHttpRequestsConfigurer.java | 54 +++- .../ExceptionHandlingConfigurer.java | 35 ++- .../web/configurers/MfaConfigurer.java | 249 ++++++++++++++++++ 3 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java index 38858ecd95..8e72e7d840 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java @@ -16,6 +16,8 @@ package org.springframework.security.config.annotation.web.configurers; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.function.Function; import java.util.function.Supplier; @@ -28,11 +30,13 @@ import org.springframework.security.access.hierarchicalroles.NullRoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthoritiesAuthorizationManager; import org.springframework.security.authorization.AuthorityAuthorizationManager; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationEventPublisher; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.authorization.AuthorizationManagers; +import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.authorization.SingleResultAuthorizationManager; import org.springframework.security.authorization.SpringAuthorizationEventPublisher; import org.springframework.security.config.ObjectPostProcessor; @@ -139,6 +143,8 @@ public final class AuthorizationManagerRequestMatcherRegistry private final RequestMatcherDelegatingAuthorizationManager.Builder managerBuilder = RequestMatcherDelegatingAuthorizationManager .builder(); + private final HasAllAuthoritiesAuthorizationManager hasAuthority = new HasAllAuthoritiesAuthorizationManager<>(); + private List unmappedMatchers; private int mappingCount; @@ -165,6 +171,7 @@ private AuthorizationManager createAuthorizationManager() { + ". Try completing it with something like requestUrls()..hasRole('USER')"); Assert.state(this.mappingCount > 0, "At least one mapping is required (for example, authorizeHttpRequests().anyRequest().authenticated())"); + this.hasAuthority.setRoleHierarchy(AuthorizeHttpRequestsConfigurer.this.roleHierarchy.get()); AuthorizationManager manager = postProcess( (AuthorizationManager) this.managerBuilder.build()); return AuthorizeHttpRequestsConfigurer.this.postProcessor.postProcess(manager); @@ -173,7 +180,7 @@ private AuthorizationManager createAuthorizationManager() { @Override protected AuthorizedUrl chainRequestMatchers(List requestMatchers) { this.unmappedMatchers = requestMatchers; - return new AuthorizedUrl(requestMatchers); + return new AuthorizedUrl(this, requestMatchers); } /** @@ -188,6 +195,10 @@ public AuthorizationManagerRequestMatcherRegistry withObjectPostProcessor( return this; } + void hasAuthority(String authority) { + this.hasAuthority.add(authority); + } + } /** @@ -199,6 +210,8 @@ public AuthorizationManagerRequestMatcherRegistry withObjectPostProcessor( */ public class AuthorizedUrl { + private final AuthorizationManagerRequestMatcherRegistry registry; + private final List matchers; private boolean not; @@ -207,7 +220,8 @@ public class AuthorizedUrl { * Creates an instance. * @param matchers the {@link RequestMatcher} instances to map */ - AuthorizedUrl(List matchers) { + AuthorizedUrl(AuthorizationManagerRequestMatcherRegistry registry, List matchers) { + this.registry = registry; this.matchers = matchers; } @@ -289,10 +303,10 @@ public AuthorizationManagerRequestMatcherRegistry hasAnyAuthority(String... auth return access(withRoleHierarchy(AuthorityAuthorizationManager.hasAnyAuthority(authorities))); } - private AuthorityAuthorizationManager withRoleHierarchy( + private AuthorizationManager withRoleHierarchy( AuthorityAuthorizationManager manager) { manager.setRoleHierarchy(AuthorizeHttpRequestsConfigurer.this.roleHierarchy.get()); - return manager; + return withAuthentication(manager); } /** @@ -301,7 +315,7 @@ private AuthorityAuthorizationManager withRoleHiera * customizations */ public AuthorizationManagerRequestMatcherRegistry authenticated() { - return access(AuthenticatedAuthorizationManager.authenticated()); + return access(withAuthentication(AuthenticatedAuthorizationManager.authenticated())); } /** @@ -313,7 +327,7 @@ public AuthorizationManagerRequestMatcherRegistry authenticated() { * @see RememberMeConfigurer */ public AuthorizationManagerRequestMatcherRegistry fullyAuthenticated() { - return access(AuthenticatedAuthorizationManager.fullyAuthenticated()); + return access(withAuthentication(AuthenticatedAuthorizationManager.fullyAuthenticated())); } /** @@ -324,7 +338,7 @@ public AuthorizationManagerRequestMatcherRegistry fullyAuthenticated() { * @see RememberMeConfigurer */ public AuthorizationManagerRequestMatcherRegistry rememberMe() { - return access(AuthenticatedAuthorizationManager.rememberMe()); + return access(withAuthentication(AuthenticatedAuthorizationManager.rememberMe())); } /** @@ -366,6 +380,11 @@ public AuthorizationManagerRequestMatcherRegistry access( : AuthorizeHttpRequestsConfigurer.this.addMapping(this.matchers, manager); } + private AuthorizationManager withAuthentication( + AuthorizationManager manager) { + return AuthorizationManagers.allOf(this.registry.hasAuthority, manager); + } + /** * An object that allows configuring {@link RequestMatcher}s with URI path * variables @@ -403,4 +422,25 @@ public AuthorizationManagerRequestMatcherRegistry equalTo(Function implements AuthorizationManager { + + private final AuthoritiesAuthorizationManager delegate = AuthoritiesAuthorizationManager.hasAllAuthorities(); + + private final Collection authorities = new ArrayList<>(); + + @Override + public AuthorizationResult authorize(Supplier authentication, T object) { + return this.delegate.authorize(authentication, this.authorities); + } + + private void setRoleHierarchy(RoleHierarchy hierarchy) { + this.delegate.setRoleHierarchy(hierarchy); + } + + private void add(String authority) { + this.authorities.add(authority); + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java index 5dc63fecdf..37c78c885d 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java @@ -16,12 +16,17 @@ package org.springframework.security.config.annotation.web.configurers; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; +import java.util.function.Consumer; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.AuthorizationEntryPoint; +import org.springframework.security.web.AuthorizationRequestingAccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandlerImpl; import org.springframework.security.web.access.ExceptionTranslationFilter; @@ -75,6 +80,8 @@ public final class ExceptionHandlingConfigurer> private LinkedHashMap defaultDeniedHandlerMappings = new LinkedHashMap<>(); + private final List authorizationRequestEntries = new ArrayList<>(); + /** * Creates a new instance * @see HttpSecurity#exceptionHandling(Customizer) @@ -82,6 +89,12 @@ public final class ExceptionHandlingConfigurer> public ExceptionHandlingConfigurer() { } + public ExceptionHandlingConfigurer authorizationEntryPoint( + Consumer> entriesConsumer) { + entriesConsumer.accept(this.authorizationRequestEntries); + return this; + } + /** * Shortcut to specify the {@link AccessDeniedHandler} to be used is a specific error * page @@ -203,7 +216,8 @@ public void configure(H http) { AccessDeniedHandler getAccessDeniedHandler(H http) { AccessDeniedHandler deniedHandler = this.accessDeniedHandler; if (deniedHandler == null) { - deniedHandler = createDefaultDeniedHandler(http); + deniedHandler = createAccessDeniedHandler(http); + } return deniedHandler; } @@ -223,15 +237,26 @@ AuthenticationEntryPoint getAuthenticationEntryPoint(H http) { return entryPoint; } - private AccessDeniedHandler createDefaultDeniedHandler(H http) { + private AccessDeniedHandler createAccessDeniedHandler(H http) { + AccessDeniedHandler defaultAccessDeniedHandler = createDefaultAccessDeniedHandler(); if (this.defaultDeniedHandlerMappings.isEmpty()) { - return new AccessDeniedHandlerImpl(); + return defaultAccessDeniedHandler; } if (this.defaultDeniedHandlerMappings.size() == 1) { - return this.defaultDeniedHandlerMappings.values().iterator().next(); + return defaultAccessDeniedHandler; } return new RequestMatcherDelegatingAccessDeniedHandler(this.defaultDeniedHandlerMappings, - new AccessDeniedHandlerImpl()); + defaultAccessDeniedHandler); + } + + private AccessDeniedHandler createDefaultAccessDeniedHandler() { + if (!this.authorizationRequestEntries.isEmpty()) { + return new AuthorizationRequestingAccessDeniedHandler(this.authorizationRequestEntries); + } + if (this.defaultDeniedHandlerMappings.isEmpty()) { + return new AccessDeniedHandlerImpl(); + } + return this.defaultDeniedHandlerMappings.values().iterator().next(); } private AuthenticationEntryPoint createDefaultEntryPoint(H http) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java new file mode 100644 index 0000000000..39e1b4a250 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java @@ -0,0 +1,249 @@ +/* + * 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.web.configurers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.function.Supplier; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NullMarked; + +import org.springframework.context.ApplicationContext; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authorization.AuthorizationRequest; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.SecurityConfigurer; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.AuthenticationResult; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.AuthorizationEntryPoint; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; +import org.springframework.util.Assert; + +public final class MfaConfigurer> + implements SecurityConfigurer { + + private final Customizer> authorize; + + private final Customizer> exceptions; + + private Supplier entryPoint = Http403ForbiddenEntryPoint::new; + + private AuthoritiesGranter authoritiesGranter; + + public MfaConfigurer(String authority, SecurityConfigurerAdapter configurer) { + this.authoritiesGranter = new SimpleAuthoritiesGranter(authority); + this.authorize = (a) -> a.getRegistry().hasAuthority(authority); + this.exceptions = (e) -> e.authorizationEntryPoint( + (p) -> p.add(new SimpleAuthorizationEntryPoint(this.entryPoint.get(), this.authoritiesGranter))); + configurer.addObjectPostProcessor(new ObjectPostProcessor() { + @Override + public AuthenticationManager postProcess(AuthenticationManager object) { + return new AuthoritiesGranterAuthenticationManager(object, MfaConfigurer.this.authoritiesGranter); + } + }); + } + + public MfaConfigurer authenticationEntryPoint(Supplier entryPoint) { + this.entryPoint = entryPoint; + return this; + } + + public MfaConfigurer authenticationEntryPoint(AuthenticationEntryPoint entryPoint) { + this.entryPoint = () -> entryPoint; + return this; + } + + private MfaConfigurer grants(AuthoritiesGranter granter) { + this.authoritiesGranter = new CompositeAuthoritiesGranter(this.authoritiesGranter, granter); + return this; + } + + public MfaConfigurer grants(String... authority) { + return grants(new SimpleAuthoritiesGranter(authority)); + } + + @Override + public void init(B http) { + ApplicationContext context = http.getSharedObject(ApplicationContext.class); + SecurityContextHolderStrategy strategy = context.getBeanProvider(SecurityContextHolderStrategy.class) + .getIfUnique(SecurityContextHolder::getContextHolderStrategy); + grants(new PreAuthenticatedAuthoritiesGranter(strategy)); + this.authorize.customize(http.getConfigurer(AuthorizeHttpRequestsConfigurer.class)); + this.exceptions.customize(http.getConfigurer(ExceptionHandlingConfigurer.class)); + } + + @Override + public void configure(B builder) throws Exception { + + } + + interface AuthoritiesGranter { + + AuthenticationResult grantAuthorities(AuthenticationResult authentication); + + default Collection grantableAuthorities() { + return List.of(); + } + + } + + static final class PreAuthenticatedAuthoritiesGranter implements AuthoritiesGranter { + + private final SecurityContextHolderStrategy strategy; + + PreAuthenticatedAuthoritiesGranter(SecurityContextHolderStrategy strategy) { + this.strategy = strategy; + } + + @Override + public AuthenticationResult grantAuthorities(AuthenticationResult authentication) { + Authentication current = this.strategy.getContext().getAuthentication(); + if (current == null || !current.isAuthenticated()) { + return authentication; + } + return authentication.withGrantedAuthorities((a) -> a.addAll(current.getAuthorities())); + } + + } + + static final class CompositeAuthoritiesGranter implements AuthoritiesGranter { + + private final Collection authoritiesGranters; + + CompositeAuthoritiesGranter(AuthoritiesGranter... authorities) { + this.authoritiesGranters = List.of(authorities); + } + + CompositeAuthoritiesGranter(Collection authorities) { + this.authoritiesGranters = new ArrayList<>(authorities); + } + + @Override + public Collection grantableAuthorities() { + Collection grantable = new ArrayList<>(); + for (AuthoritiesGranter granter : this.authoritiesGranters) { + grantable.addAll(granter.grantableAuthorities()); + } + return grantable; + } + + @Override + public AuthenticationResult grantAuthorities(AuthenticationResult authentication) { + AuthenticationResult granted = authentication; + for (AuthoritiesGranter granter : this.authoritiesGranters) { + granted = granter.grantAuthorities(granted); + } + return granted; + } + + } + + static final class SimpleAuthoritiesGranter implements AuthoritiesGranter { + + private final Collection authorities; + + SimpleAuthoritiesGranter(String... authorities) { + this.authorities = List.of(authorities); + } + + @Override + public Collection grantableAuthorities() { + return this.authorities; + } + + @Override + public AuthenticationResult grantAuthorities(AuthenticationResult authentication) { + Collection toGrant = new HashSet<>(); + for (String authority : this.authorities) { + toGrant.add(new SimpleGrantedAuthority(authority)); + } + Collection current = new HashSet<>(authentication.getAuthorities()); + toGrant.addAll(current); + return authentication.withGrantedAuthorities(toGrant); + } + + } + + @NullMarked + static final class AuthoritiesGranterAuthenticationManager implements AuthenticationManager { + + private final AuthenticationManager authenticationManager; + + private final AuthoritiesGranter authoritiesGranter; + + AuthoritiesGranterAuthenticationManager(AuthenticationManager manager, AuthoritiesGranter granter) { + this.authenticationManager = manager; + this.authoritiesGranter = granter; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + Authentication result = this.authenticationManager.authenticate(authentication); + Assert.isInstanceOf(AuthenticationResult.class, result, "must be of type AuthenticationResult"); + return this.authoritiesGranter.grantAuthorities((AuthenticationResult) result); + } + + } + + static final class SimpleAuthorizationEntryPoint implements AuthorizationEntryPoint { + + private final AuthoritiesGranter authoritiesGranter; + + private final AuthenticationEntryPoint authenticationEntryPoint; + + SimpleAuthorizationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint, + AuthoritiesGranter authoritiesGranter) { + this.authoritiesGranter = authoritiesGranter; + this.authenticationEntryPoint = authenticationEntryPoint; + } + + @Override + public boolean authorizes(AuthorizationRequest authorizationRequest) { + Collection grantable = this.authoritiesGranter.grantableAuthorities(); + for (GrantedAuthority needed : authorizationRequest.getAuthorities()) { + if (grantable.contains(needed.getAuthority())) { + return true; + } + } + return false; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + this.authenticationEntryPoint.commence(request, response, authException); + } + + } + +} From 5c2a9773e90b1502dd7055ae813298fd8de56560 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:06:01 -0600 Subject: [PATCH 07/12] Add MFA to Existing Authentication Mechanisms --- .../web/configurers/FormLoginConfigurer.java | 14 ++++++++++++ .../web/configurers/HttpBasicConfigurer.java | 14 ++++++++++++ .../web/configurers/WebAuthnConfigurer.java | 20 +++++++++++++++++ .../web/configurers/X509Configurer.java | 18 +++++++++++++++ .../oauth2/client/OAuth2LoginConfigurer.java | 15 +++++++++++++ .../OAuth2ResourceServerConfigurer.java | 15 +++++++++++++ .../ott/OneTimeTokenLoginConfigurer.java | 22 +++++++++++++++++++ .../saml2/Saml2LoginConfigurer.java | 15 +++++++++++++ 8 files changed, 133 insertions(+) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java index 03cf95b390..519a9d97eb 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java @@ -72,6 +72,8 @@ public final class FormLoginConfigurer> extends AbstractAuthenticationFilterConfigurer, UsernamePasswordAuthenticationFilter> { + private MfaConfigurer mfa; + /** * Creates a new instance * @see HttpSecurity#formLogin(Customizer) @@ -227,8 +229,20 @@ public FormLoginConfigurer successForwardUrl(String forwardUrl) { return this; } + public FormLoginConfigurer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_FORM", this); + this.mfa.authenticationEntryPoint(this::getAuthenticationEntryPoint); + } + customizer.customize(this.mfa); + return this; + } + @Override public void init(H http) throws Exception { + if (this.mfa != null) { + this.mfa.init(http); + } super.init(http); initDefaultLoginFilter(http); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java index 724bab19a7..8bbf149d96 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java @@ -89,6 +89,8 @@ public final class HttpBasicConfigurer> private static final String DEFAULT_REALM = "Realm"; + private MfaConfigurer mfa; + private AuthenticationEntryPoint authenticationEntryPoint; private AuthenticationDetailsSource authenticationDetailsSource; @@ -161,8 +163,20 @@ public HttpBasicConfigurer securityContextRepository(SecurityContextRepositor return this; } + public HttpBasicConfigurer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_BASIC", this); + this.mfa.authenticationEntryPoint(() -> this.authenticationEntryPoint); + } + customizer.customize(this.mfa); + return this; + } + @Override public void init(B http) { + if (this.mfa != null) { + this.mfa.init(http); + } registerDefaults(http); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java index 671c03c303..b81c0049ae 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java @@ -26,9 +26,11 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; @@ -59,6 +61,8 @@ public class WebAuthnConfigurer> extends AbstractHttpConfigurer, H> { + private MfaConfigurer mfa; + private String rpId; private String rpName; @@ -151,6 +155,22 @@ public WebAuthnConfigurer creationOptionsRepository( return this; } + public WebAuthnConfigurer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_WEBAUTHN", this) + .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")); + } + customizer.customize(this.mfa); + return this; + } + + @Override + public void init(H http) throws Exception { + if (this.mfa != null) { + this.mfa.init(http); + } + } + @Override public void configure(H http) throws Exception { UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java index ad52680081..e8050b285c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java @@ -37,6 +37,7 @@ import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; /** * Adds X509 based pre authentication to an application. Since validating the certificate @@ -80,6 +81,8 @@ public final class X509Configurer> extends AbstractHttpConfigurer, H> { + private MfaConfigurer mfa; + private X509AuthenticationFilter x509AuthenticationFilter; private X509PrincipalExtractor x509PrincipalExtractor; @@ -173,12 +176,27 @@ public X509Configurer subjectPrincipalRegex(String subjectPrincipalRegex) { return this; } + public X509Configurer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_X509", this); + } + customizer.customize(this.mfa); + return this; + } + @Override public void init(H http) { PreAuthenticatedAuthenticationProvider authenticationProvider = new PreAuthenticatedAuthenticationProvider(); authenticationProvider.setPreAuthenticatedUserDetailsService(getAuthenticationUserDetailsService(http)); http.authenticationProvider(authenticationProvider) .setSharedObject(AuthenticationEntryPoint.class, new Http403ForbiddenEntryPoint()); + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + exceptions.defaultAuthenticationEntryPointFor(new Http403ForbiddenEntryPoint(), AnyRequestMatcher.INSTANCE); + } + if (this.mfa != null) { + this.mfa.init(http); + } } @Override diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java index c05424e350..74e27d3535 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java @@ -41,6 +41,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.MfaConfigurer; import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; import org.springframework.security.context.DelegatingApplicationListener; import org.springframework.security.core.Authentication; @@ -172,6 +173,8 @@ public final class OAuth2LoginConfigurer> private final UserInfoEndpointConfig userInfoEndpointConfig = new UserInfoEndpointConfig(); + private MfaConfigurer mfa; + private String loginPage; private String loginProcessingUrl = OAuth2LoginAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI; @@ -305,8 +308,20 @@ public OAuth2LoginConfigurer userInfoEndpoint(Customizer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_OAUTH2", this); + this.mfa.authenticationEntryPoint(this::getAuthenticationEntryPoint); + } + customizer.customize(this.mfa); + return this; + } + @Override public void init(B http) throws Exception { + if (this.mfa != null) { + this.mfa.init(http); + } OAuth2LoginAuthenticationFilter authenticationFilter = new OAuth2LoginAuthenticationFilter( this.getClientRegistrationRepository(), this.getAuthorizedClientRepository(), this.loginProcessingUrl); RequestMatcher processUri = getRequestMatcherBuilder().matcher(this.loginProcessingUrl); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index f2591c7343..7257759d79 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -36,6 +36,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer; +import org.springframework.security.config.annotation.web.configurers.MfaConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -161,6 +162,8 @@ public final class OAuth2ResourceServerConfigurer mfa; + private final ApplicationContext context; private AuthenticationManagerResolver authenticationManagerResolver; @@ -249,8 +252,20 @@ public OAuth2ResourceServerConfigurer opaqueToken(Customizer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_BEARER", this); + this.mfa.authenticationEntryPoint(() -> this.authenticationEntryPoint); + } + customizer.customize(this.mfa); + return this; + } + @Override public void init(H http) { + if (this.mfa != null) { + this.mfa.init(http); + } validateConfiguration(); registerDefaultAccessDeniedHandler(http); registerDefaultEntryPoint(http); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java index 4f01a17e5e..ad1202dd79 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java @@ -35,11 +35,14 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.MfaConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.PostAuthenticationEntryPoint; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver; @@ -104,6 +107,8 @@ public final class OneTimeTokenLoginConfigurer> private final ApplicationContext context; + private MfaConfigurer mfa; + private OneTimeTokenService oneTimeTokenService; private String defaultSubmitPageUrl = DefaultOneTimeTokenSubmitPageGeneratingFilter.DEFAULT_SUBMIT_PAGE_URL; @@ -127,6 +132,9 @@ public OneTimeTokenLoginConfigurer(ApplicationContext context) { @Override public void init(H http) throws Exception { + if (this.mfa != null) { + this.mfa.init(http); + } if (getLoginProcessingUrl() == null) { loginProcessingUrl(OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL); } @@ -359,6 +367,20 @@ public OneTimeTokenLoginConfigurer generateRequestResolver(GenerateOneTimeTok return this; } + public OneTimeTokenLoginConfigurer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_OTT", this); + this.mfa.authenticationEntryPoint(this::getPostAuthenticationEntryPoint); + } + customizer.customize(this.mfa); + return this; + } + + private AuthenticationEntryPoint getPostAuthenticationEntryPoint() { + String postUrl = this.tokenGeneratingUrl + "?username={u}"; + return new PostAuthenticationEntryPoint(postUrl, Map.of("u", Authentication::getName)); + } + private GenerateOneTimeTokenRequestResolver getGenerateRequestResolver() { if (this.requestResolver != null) { return this.requestResolver; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index 664ae446b0..9e4a6296f0 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -33,6 +33,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.annotation.web.configurers.MfaConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; @@ -121,6 +122,8 @@ public final class Saml2LoginConfigurer> private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5"); + private MfaConfigurer mfa; + private String loginPage; private String authenticationRequestUri = "/saml2/authenticate"; @@ -256,6 +259,15 @@ public Saml2LoginConfigurer loginProcessingUrl(String loginProcessingUrl) { return this; } + public Saml2LoginConfigurer factor(Customizer> customizer) { + if (this.mfa == null) { + this.mfa = new MfaConfigurer<>("AUTHN_SAML2", this); + this.mfa.authenticationEntryPoint(this::getAuthenticationEntryPoint); + } + customizer.customize(this.mfa); + return this; + } + @Override protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { return getRequestMatcherBuilder().matcher(loginProcessingUrl); @@ -276,6 +288,9 @@ protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingU */ @Override public void init(B http) throws Exception { + if (this.mfa != null) { + this.mfa.init(http); + } registerDefaultCsrfOverride(http); relyingPartyRegistrationRepository(http); this.saml2WebSsoAuthenticationFilter = new Saml2WebSsoAuthenticationFilter(getAuthenticationConverter(http)); From cdcb38f3854623cddfd8c5e110aed4f554108c69 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:28:14 -0600 Subject: [PATCH 08/12] Add Expirable Authorities Allowing individual authorities to expire offers enormous flexibility as far as granting authorities that need to be renewed independently from logging in. --- .../security/SerializationSamples.java | 3 + ...ority.ExpirableGrantedAuthority.serialized | Bin 0 -> 318 bytes .../AuthoritiesAuthorizationManager.java | 11 ++- .../security/core/GrantedAuthority.java | 4 + .../authority/ExpirableGrantedAuthority.java | 73 ++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.ExpirableGrantedAuthority.serialized create mode 100644 core/src/main/java/org/springframework/security/core/authority/ExpirableGrantedAuthority.java diff --git a/config/src/test/java/org/springframework/security/SerializationSamples.java b/config/src/test/java/org/springframework/security/SerializationSamples.java index b453833268..db01e15721 100644 --- a/config/src/test/java/org/springframework/security/SerializationSamples.java +++ b/config/src/test/java/org/springframework/security/SerializationSamples.java @@ -92,6 +92,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.ExpirableGrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.context.TransientSecurityContext; @@ -375,6 +376,8 @@ final class SerializationSamples { generatorByClassName.put(AlreadyBuiltException.class, (r) -> new AlreadyBuiltException("message")); // core + generatorByClassName.put(ExpirableGrantedAuthority.class, + (r) -> new ExpirableGrantedAuthority("a", Instant.now())); generatorByClassName.put(RunAsUserToken.class, (r) -> { RunAsUserToken token = new RunAsUserToken("key", user, "creds", user.getAuthorities(), AnonymousAuthenticationToken.class); diff --git a/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.ExpirableGrantedAuthority.serialized b/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.ExpirableGrantedAuthority.serialized new file mode 100644 index 0000000000000000000000000000000000000000..c49f259993a8fd64c1ce8afdb04cfa55b6407dd1 GIT binary patch literal 318 zcmZ4UmVvdnh`}|#C|$3(peQphJ*_A)H?=&!C|j>MHMz7Xv!qflIlm}XFR`>FBOlCl zttiMWN=(X0buUWHD@jdpgvnUmwtZfG^nEK61G5hUCtO1bgOE>FVp*boPGVlVesBrM z7Hb~{*5sW0r?O*{+?6$Rx?91Io3g$#8JoaMYs3_y@k=zD~}sH3z30F{AmlK=n! literal 0 HcmV?d00001 diff --git a/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java index 1eb59488e9..ac84afedfa 100644 --- a/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java @@ -18,7 +18,6 @@ import java.util.Collection; import java.util.HashSet; -import java.util.List; import java.util.Set; import java.util.function.Supplier; @@ -90,10 +89,16 @@ public AuthorizationResult authorize(Supplier authentication, Co } private Collection getGrantedAuthorities(Authentication authentication) { + Collection authorities = new HashSet<>(); if (authentication == null) { - return List.of(); + return authorities; } - return new HashSet<>(this.roleHierarchy.getReachableGrantedAuthorities(authentication.getAuthorities())); + for (GrantedAuthority authority : authentication.getAuthorities()) { + if (authority.isGranted()) { + authorities.add(authority); + } + } + return new HashSet<>(this.roleHierarchy.getReachableGrantedAuthorities(authorities)); } } diff --git a/core/src/main/java/org/springframework/security/core/GrantedAuthority.java b/core/src/main/java/org/springframework/security/core/GrantedAuthority.java index 143b254b85..6fd13a5047 100644 --- a/core/src/main/java/org/springframework/security/core/GrantedAuthority.java +++ b/core/src/main/java/org/springframework/security/core/GrantedAuthority.java @@ -48,4 +48,8 @@ public interface GrantedAuthority extends Serializable { */ String getAuthority(); + default boolean isGranted() { + return true; + } + } diff --git a/core/src/main/java/org/springframework/security/core/authority/ExpirableGrantedAuthority.java b/core/src/main/java/org/springframework/security/core/authority/ExpirableGrantedAuthority.java new file mode 100644 index 0000000000..19512a3c25 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/authority/ExpirableGrantedAuthority.java @@ -0,0 +1,73 @@ +/* + * 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.core.authority; + +import java.io.Serial; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; + +public final class ExpirableGrantedAuthority implements GrantedAuthority { + + @Serial + private static final long serialVersionUID = 4168993944484835205L; + + private final String authority; + + private final Instant expiresAt; + + private Clock clock = Clock.systemUTC(); + + public ExpirableGrantedAuthority(String authority, Instant expiresAt) { + Assert.notNull(authority, "authority cannot be null"); + Assert.notNull(expiresAt, "expiresAt cannot be null"); + this.authority = authority; + this.expiresAt = expiresAt; + } + + @Override + public String getAuthority() { + return this.authority; + } + + @Override + public boolean isGranted() { + return this.clock.instant().isAfter(this.expiresAt); + } + + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof GrantedAuthority that)) { + return false; + } + return Objects.equals(this.authority, that.getAuthority()); + } + + @Override + public int hashCode() { + return Objects.hashCode(this.authority); + } + +} From 1ef141ee60e7e8eaa7335b1dd708b76987cd9a42 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:28:59 -0600 Subject: [PATCH 09/12] Support expiring authorities in DSL --- .../web/configurers/MfaConfigurer.java | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java index 39e1b4a250..af4b467363 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java @@ -17,6 +17,9 @@ package org.springframework.security.config.annotation.web.configurers; import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; @@ -27,6 +30,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import org.springframework.context.ApplicationContext; import org.springframework.security.authentication.AuthenticationManager; @@ -40,6 +44,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.ExpirableGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; @@ -92,6 +97,10 @@ public MfaConfigurer grants(String... authority) { return grants(new SimpleAuthoritiesGranter(authority)); } + public MfaConfigurer grants(Duration duration, String... authority) { + return grants(new SimpleAuthoritiesGranter(duration, authority)); + } + @Override public void init(B http) { ApplicationContext context = http.getSharedObject(ApplicationContext.class); @@ -170,9 +179,20 @@ public AuthenticationResult grantAuthorities(AuthenticationResult authentication static final class SimpleAuthoritiesGranter implements AuthoritiesGranter { + private final @Nullable Duration grantingTime; + private final Collection authorities; + private Clock clock = Clock.systemUTC(); + SimpleAuthoritiesGranter(String... authorities) { + this.grantingTime = null; + this.authorities = List.of(authorities); + } + + SimpleAuthoritiesGranter(Duration grantingTime, String... authorities) { + Assert.notEmpty(authorities, "authorities cannot be empty"); + this.grantingTime = grantingTime; this.authorities = List.of(authorities); } @@ -185,13 +205,23 @@ public Collection grantableAuthorities() { public AuthenticationResult grantAuthorities(AuthenticationResult authentication) { Collection toGrant = new HashSet<>(); for (String authority : this.authorities) { - toGrant.add(new SimpleGrantedAuthority(authority)); + if (this.grantingTime == null) { + toGrant.add(new SimpleGrantedAuthority(authority)); + } + else { + Instant expiresAt = this.clock.instant().plus(this.grantingTime); + toGrant.add(new ExpirableGrantedAuthority(authority, expiresAt)); + } } Collection current = new HashSet<>(authentication.getAuthorities()); toGrant.addAll(current); return authentication.withGrantedAuthorities(toGrant); } + void setClock(Clock clock) { + this.clock = clock; + } + } @NullMarked From 293553c7027fbb714a3b4a3b6d6f66946f202fc0 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:29:09 -0600 Subject: [PATCH 10/12] Add Simple Tests --- .../configurers/FormLoginConfigurerTests.java | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java index ebe66c3bbe..2cdceb6db1 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java @@ -16,6 +16,8 @@ package org.springframework.security.config.annotation.web.configurers; +import java.time.Duration; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,6 +25,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.Customizer; import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -34,9 +37,13 @@ import org.springframework.security.core.context.SecurityContextChangedListener; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.PasswordEncodedUser; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.security.web.PortMapper; import org.springframework.security.web.PortResolver; import org.springframework.security.web.SecurityFilterChain; @@ -44,10 +51,13 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; @@ -61,6 +71,7 @@ import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -386,6 +397,59 @@ public void configureWhenPortResolverBeanThenPortResolverUsed() throws Exception verify(this.spring.getContext().getBean(PortResolver.class)).getServerPort(any()); } + @Test + void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslConfig.class).autowire(); + UserDetails user = PasswordEncodedUser.user(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + this.mockMvc + .perform(post("/ott/generate").param("username", "user") + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/ott/sent")); + this.mockMvc + .perform(post("/login").param("username", user.getUsername()) + .param("password", user.getPassword()) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "AUTHN_OTT").build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "AUTHN_FORM").build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("/ott/generate"))); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "AUTHN_FORM", "AUTHN_OTT").build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().isNotFound()); + } + + @Test + void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslX509Config.class).autowire(); + this.mockMvc.perform(get("/")).andExpect(status().isForbidden()); + this.mockMvc.perform(get("/login")).andExpect(status().isOk()); + this.mockMvc.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + UserDetails user = PasswordEncodedUser.withUsername("rod") + .password("password") + .authorities("AUTHN_FORM") + .build(); + this.mockMvc + .perform(post("/login").param("username", user.getUsername()) + .param("password", user.getPassword()) + .with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + } + @Configuration @EnableWebSecurity static class RequestCacheConfig { @@ -751,4 +815,64 @@ public O postProcess(O object) { } + @Configuration + @EnableWebSecurity + static class MfaDslConfig { + + private static final Duration FIVE_MINUTES = Duration.ofMinutes(5); + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .formLogin((form) -> form.factor((f) -> f.grants(FIVE_MINUTES, "profile:read"))) + .oneTimeTokenLogin((ott) -> ott.factor(Customizer.withDefaults())) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile").hasAuthority("profile:read") + .anyRequest().authenticated() + ); + return http.build(); + // @formatter:on + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.user()); + } + + @Bean + PasswordEncoder encoder() { + return NoOpPasswordEncoder.getInstance(); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } + + } + + @Configuration + @EnableWebSecurity + static class MfaDslX509Config { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .formLogin((form) -> form.factor(Customizer.withDefaults())) + .x509((x509) -> x509.factor(Customizer.withDefaults())) + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()); + return http.build(); + // @formatter:on + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager( + PasswordEncodedUser.withUsername("rod").password("{noop}password").build()); + } + + } + } From e64140336724637a1d08f3f7f5dc34e91dec4a9d Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:29:15 -0600 Subject: [PATCH 11/12] Add Sample Doc --- .../pages/servlet/authentication/mfa.adoc | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/modules/ROOT/pages/servlet/authentication/mfa.adoc diff --git a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc new file mode 100644 index 0000000000..dc66eadd25 --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc @@ -0,0 +1,186 @@ += Multi-Factor Authentication + +Spring Security 7+ supports Multi-factor Authentication. +This means that you can supply multiple authentication mechanisms and require that they be provided in a certain order for the principal to be deemed fully authenticated. + +This is provided by way of authorities that represent each completed authentication. +For example, when form login is completed, the resulting authentication will include an `AUTHN_FORM` granted authority. + +This means that you can require that a certain endpoint require form login by specifying the authority in the `authorizeHttpRequests` DSL like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityFilterChain filterchain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().hasAuthority("AUTHN_FORM") + ) + .formLogin((form) -> form.factor(Customizer.withDefaults())); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun filterChain(val http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(anyRequest, hasAuthority("AUTHN_FORM")) + formLogin { + factor {} + } + } + } + return http.build() +} +---- +====== + +== Architecture + +Each authentication factor in Spring Security is represented by a filter in the filter chain. +Any authentication factor participating in multi-factor authentication is augmented in three ways: + +1. Its authentication manager grants at least an authority that represents the completed authorization, for example, `AUTHN_FORM` for form login `AUTHN_BEARER` for bearer tokens. +2. It adds its `AUTHN_XXX` authority as a default-required authority to the `authorizeHttpRequests` DSL. +3. It registers an `AuthorizationEntryPoint` to the `exceptionHandling` DSL to indicate what authorization requests it can grant and how to request them. + +== Requiring More Than One Factor + +You can register any Spring Security authentication mechanism as an authentication factor using the exposed `.factor` DSL like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .x509((x509) -> x509.factor(Customizer.withDefaults())) + .formLogin((form) -> form.factor(Customizer.withDefaults())); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun filterChain(val http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + x509 { + factor {} + } + formLogin { + factor {} + } + } + } +} +---- +====== + +[TIP] +===== +You can identity additional authentication mechanisms that are not a factor in your multi-factor authentication setup. +In this case, you should use `.access()` to override any default authentication manager that is added to other authorization rules. +Or, you can publish a custom `AuthorizationManagerFactory` bean. +===== + +[TIP] +===== +Generally speaking, the order in which you declare the factors is the order in which Spring Security will attempt them when collecting the needed authorities. +Note however, if you have overridden the `AuthenticationEntryPoint` or are using factors that register a `defaultAuthenticationEntryPointFor`, these will take precedence. +===== + +== Granting additional authorities + +You may want to grant additional authorities. +This can be done in the DSL by stating the name of the authority tied to the mechanism. + +For example, you may have an authority that you want to grant for only a few minutes, so that it can be asked for again later on in the case of a more highly-sensitive page: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile/**").hasAuthority("profile:read") + .anyRequest().authenticated() + ) + .x509((x509) -> x509.factor(Customizer.withDefaults())) + .formLogin((form) -> form.factor((f) -> f + .grants(Duration.ofMinutes(5), "profile:read") + )); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun filterChain(val http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/profile/**", hasAuthority("profile:read")) + authorize(anyRequest, authenticated) + x509 { + factor {} + } + formLogin { + factor { + grants(Duration.ofMinutes(5), "profile:read") + } + } + } + } +} +---- +====== + +The above indicates that the `/profile/**` endpoints are more sensitive and require re-authorization in order to go to them, if that authorization hasn't been obtained in the last five minutes. +It further states that the "profile:read" authority can be re-obtained using form login. + +== Registering a custom entry point + +Sometimes the way an authentication factor works when already logged in is different then when you are not yet logged in. + +For example, a form login page may not need you to provide the username again, only the credentials. +Or an OTT login page may simply auto-POST since it already has all the information it needs to generate the token. + +You can register a custom `AuthenticationEntryPoint` to indicate post-authentication behavior by calling the `authenticationEntryPoint` method in the `factor` DSL. + +[TIP] +==== +A handy implementation is `PostAuthenticationEntryPoint`, which uses `FormPostRedirectStrategy` to create an auto-POST page. +==== + +[NOTE] +==== +Like other nested DSLs in Spring Security, if you have a custom `AuthenticationEntryPoint` in your factor's main configuration, you will either need to provide that or the appropriate post-authentication version to the `factor` DSL. +==== + +== Custom authentication factors + +You can provide a custom authentication factor by using the `MfaConfigurer` configurer, which comes with the needed configuration methods to register extra authorities and a custom `AuthenticationEntryPoint`. From c33acc077843559244acd67c0e3af2c99092f33c Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 12 Aug 2025 19:14:28 -0600 Subject: [PATCH 12/12] Polish AuthoritiesGranter --- .../web/configurers/MfaConfigurer.java | 66 +++++++++---------- .../security/web/AuthorizationEntryPoint.java | 5 +- ...rizationRequestingAccessDeniedHandler.java | 2 +- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java index af4b467363..668fea4314 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java @@ -20,7 +20,6 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; @@ -44,6 +43,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationResult; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.authority.ExpirableGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -118,11 +118,9 @@ public void configure(B builder) throws Exception { interface AuthoritiesGranter { - AuthenticationResult grantAuthorities(AuthenticationResult authentication); + Collection grantableAuthorities(AuthorizationRequest request); - default Collection grantableAuthorities() { - return List.of(); - } + Collection grantableAuthorities(Authentication result); } @@ -135,12 +133,17 @@ static final class PreAuthenticatedAuthoritiesGranter implements AuthoritiesGran } @Override - public AuthenticationResult grantAuthorities(AuthenticationResult authentication) { + public Collection grantableAuthorities(AuthorizationRequest request) { + return List.of(); + } + + @Override + public Collection grantableAuthorities(Authentication result) { Authentication current = this.strategy.getContext().getAuthentication(); if (current == null || !current.isAuthenticated()) { - return authentication; + return List.of(); } - return authentication.withGrantedAuthorities((a) -> a.addAll(current.getAuthorities())); + return new HashSet<>(current.getAuthorities()); } } @@ -153,26 +156,22 @@ static final class CompositeAuthoritiesGranter implements AuthoritiesGranter { this.authoritiesGranters = List.of(authorities); } - CompositeAuthoritiesGranter(Collection authorities) { - this.authoritiesGranters = new ArrayList<>(authorities); - } - @Override - public Collection grantableAuthorities() { - Collection grantable = new ArrayList<>(); + public Collection grantableAuthorities(AuthorizationRequest request) { + Collection authorities = new HashSet<>(); for (AuthoritiesGranter granter : this.authoritiesGranters) { - grantable.addAll(granter.grantableAuthorities()); + authorities.addAll(granter.grantableAuthorities(request)); } - return grantable; + return authorities; } @Override - public AuthenticationResult grantAuthorities(AuthenticationResult authentication) { - AuthenticationResult granted = authentication; + public Collection grantableAuthorities(Authentication result) { + Collection authorities = new HashSet<>(); for (AuthoritiesGranter granter : this.authoritiesGranters) { - granted = granter.grantAuthorities(granted); + authorities.addAll(granter.grantableAuthorities(result)); } - return granted; + return authorities; } } @@ -197,12 +196,15 @@ static final class SimpleAuthoritiesGranter implements AuthoritiesGranter { } @Override - public Collection grantableAuthorities() { - return this.authorities; + public Collection grantableAuthorities(AuthorizationRequest request) { + Collection grantable = AuthorityUtils.createAuthorityList(this.authorities); + Collection requested = request.getAuthorities(); + grantable.retainAll(requested); + return grantable; } @Override - public AuthenticationResult grantAuthorities(AuthenticationResult authentication) { + public Collection grantableAuthorities(Authentication result) { Collection toGrant = new HashSet<>(); for (String authority : this.authorities) { if (this.grantingTime == null) { @@ -213,9 +215,8 @@ public AuthenticationResult grantAuthorities(AuthenticationResult authentication toGrant.add(new ExpirableGrantedAuthority(authority, expiresAt)); } } - Collection current = new HashSet<>(authentication.getAuthorities()); - toGrant.addAll(current); - return authentication.withGrantedAuthorities(toGrant); + toGrant.removeAll(result.getAuthorities()); + return toGrant; } void setClock(Clock clock) { @@ -240,7 +241,8 @@ static final class AuthoritiesGranterAuthenticationManager implements Authentica public Authentication authenticate(Authentication authentication) throws AuthenticationException { Authentication result = this.authenticationManager.authenticate(authentication); Assert.isInstanceOf(AuthenticationResult.class, result, "must be of type AuthenticationResult"); - return this.authoritiesGranter.grantAuthorities((AuthenticationResult) result); + Collection authorities = this.authoritiesGranter.grantableAuthorities(result); + return ((AuthenticationResult) result).withGrantedAuthorities((a) -> a.addAll(authorities)); } } @@ -258,14 +260,8 @@ static final class SimpleAuthorizationEntryPoint implements AuthorizationEntryPo } @Override - public boolean authorizes(AuthorizationRequest authorizationRequest) { - Collection grantable = this.authoritiesGranter.grantableAuthorities(); - for (GrantedAuthority needed : authorizationRequest.getAuthorities()) { - if (grantable.contains(needed.getAuthority())) { - return true; - } - } - return false; + public Collection grantableAuthorities(AuthorizationRequest request) { + return this.authoritiesGranter.grantableAuthorities(request); } @Override diff --git a/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java b/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java index 2886dd9d6d..f4856e7442 100644 --- a/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java +++ b/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java @@ -16,10 +16,13 @@ package org.springframework.security.web; +import java.util.Collection; + import org.springframework.security.authorization.AuthorizationRequest; +import org.springframework.security.core.GrantedAuthority; public interface AuthorizationEntryPoint extends AuthenticationEntryPoint { - boolean authorizes(AuthorizationRequest authorizationRequest); + Collection grantableAuthorities(AuthorizationRequest authorizationRequest); } diff --git a/web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java b/web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java index bd722839bb..e336045a1a 100644 --- a/web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java +++ b/web/src/main/java/org/springframework/security/web/AuthorizationRequestingAccessDeniedHandler.java @@ -51,7 +51,7 @@ public void handle(HttpServletRequest request, HttpServletResponse response, Acc return; } for (AuthorizationEntryPoint entry : this.entries) { - if (entry.authorizes(authorizationRequest)) { + if (!entry.grantableAuthorities(authorizationRequest).isEmpty()) { AuthenticationException iae = new InsufficientAuthenticationException("access denied", access); entry.commence(request, response, iae); return;