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 e19f8bd33cf..3f026724fb7 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/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 07d43cbc9e5..34d83d8b7fa 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/AuthorizeHttpRequestsConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java index 38858ecd956..8e72e7d8400 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 5dc63fecdf9..37c78c885de 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/FormLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java index 03cf95b3901..519a9d97eb9 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 ad03ae60520..8bbf149d965 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); } @@ -207,7 +221,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/MfaConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java new file mode 100644 index 00000000000..668fea43147 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MfaConfigurer.java @@ -0,0 +1,275 @@ +/* + * 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.time.Clock; +import java.time.Duration; +import java.time.Instant; +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.jspecify.annotations.Nullable; + +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.AuthorityUtils; +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; +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)); + } + + 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); + 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 { + + Collection grantableAuthorities(AuthorizationRequest request); + + Collection grantableAuthorities(Authentication result); + + } + + static final class PreAuthenticatedAuthoritiesGranter implements AuthoritiesGranter { + + private final SecurityContextHolderStrategy strategy; + + PreAuthenticatedAuthoritiesGranter(SecurityContextHolderStrategy strategy) { + this.strategy = strategy; + } + + @Override + 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 List.of(); + } + return new HashSet<>(current.getAuthorities()); + } + + } + + static final class CompositeAuthoritiesGranter implements AuthoritiesGranter { + + private final Collection authoritiesGranters; + + CompositeAuthoritiesGranter(AuthoritiesGranter... authorities) { + this.authoritiesGranters = List.of(authorities); + } + + @Override + public Collection grantableAuthorities(AuthorizationRequest request) { + Collection authorities = new HashSet<>(); + for (AuthoritiesGranter granter : this.authoritiesGranters) { + authorities.addAll(granter.grantableAuthorities(request)); + } + return authorities; + } + + @Override + public Collection grantableAuthorities(Authentication result) { + Collection authorities = new HashSet<>(); + for (AuthoritiesGranter granter : this.authoritiesGranters) { + authorities.addAll(granter.grantableAuthorities(result)); + } + return authorities; + } + + } + + 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); + } + + @Override + public Collection grantableAuthorities(AuthorizationRequest request) { + Collection grantable = AuthorityUtils.createAuthorityList(this.authorities); + Collection requested = request.getAuthorities(); + grantable.retainAll(requested); + return grantable; + } + + @Override + public Collection grantableAuthorities(Authentication result) { + Collection toGrant = new HashSet<>(); + for (String authority : this.authorities) { + if (this.grantingTime == null) { + toGrant.add(new SimpleGrantedAuthority(authority)); + } + else { + Instant expiresAt = this.clock.instant().plus(this.grantingTime); + toGrant.add(new ExpirableGrantedAuthority(authority, expiresAt)); + } + } + toGrant.removeAll(result.getAuthorities()); + return toGrant; + } + + void setClock(Clock clock) { + this.clock = clock; + } + + } + + @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"); + Collection authorities = this.authoritiesGranter.grantableAuthorities(result); + return ((AuthenticationResult) result).withGrantedAuthorities((a) -> a.addAll(authorities)); + } + + } + + 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 Collection grantableAuthorities(AuthorizationRequest request) { + return this.authoritiesGranter.grantableAuthorities(request); + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + this.authenticationEntryPoint.commence(request, response, authException); + } + + } + +} 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 7ec3279efb5..b81c0049aef 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,10 +24,13 @@ 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.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; @@ -58,6 +61,8 @@ public class WebAuthnConfigurer> extends AbstractHttpConfigurer, H> { + private MfaConfigurer mfa; + private String rpId; private String rpName; @@ -150,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) @@ -162,8 +183,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 6966d3e156d..e8050b285ce 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,17 +176,33 @@ 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 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/client/OAuth2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java index c05424e3505..74e27d35351 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 331730f1dbf..7257759d790 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); @@ -265,7 +280,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; } 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 4f01a17e5eb..ad1202dd797 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 664ae446b0b..9e4a6296f0a 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)); diff --git a/config/src/test/java/org/springframework/security/SerializationSamples.java b/config/src/test/java/org/springframework/security/SerializationSamples.java index b4538332686..db01e157217 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/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 ebe66c3bbeb..2cdceb6db19 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()); + } + + } + } 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 00000000000..c49f259993a Binary files /dev/null and b/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.ExpirableGrantedAuthority.serialized differ 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 5c17618cef8..f94c6d8cb09 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 abfc6560f45..65ad4d078c6 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 2ec7d269bfe..aaa8e284542 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 314f79e5636..7f615956f19 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 b956a3771ac..5c5f40ff27a 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/authorization/AuthoritiesAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/AuthoritiesAuthorizationManager.java index c70d67c332c..ac84afedfa2 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,8 @@ package org.springframework.security.authorization; import java.util.Collection; +import java.util.HashSet; +import java.util.Set; import java.util.function.Supplier; import org.springframework.security.access.hierarchicalroles.NullRoleHierarchy; @@ -37,6 +39,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 +74,31 @@ 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); + 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)); + } } - private boolean isAuthorized(Authentication authentication, Collection authorities) { - for (GrantedAuthority grantedAuthority : getGrantedAuthorities(authentication)) { - if (authorities.contains(grantedAuthority.getAuthority())) { - return true; + private Collection getGrantedAuthorities(Authentication authentication) { + Collection authorities = new HashSet<>(); + if (authentication == null) { + return authorities; + } + for (GrantedAuthority authority : authentication.getAuthorities()) { + if (authority.isGranted()) { + authorities.add(authority); } } - return false; - } - - private Collection getGrantedAuthorities(Authentication authentication) { - return this.roleHierarchy.getReachableGrantedAuthorities(authentication.getAuthorities()); + return new HashSet<>(this.roleHierarchy.getReachableGrantedAuthorities(authorities)); } } 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 d5f461caec5..efbdb4fd811 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 00000000000..82f80cf96ff --- /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/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 00000000000..25fb8360867 --- /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/core/src/main/java/org/springframework/security/core/GrantedAuthority.java b/core/src/main/java/org/springframework/security/core/GrantedAuthority.java index 143b254b854..6fd13a50473 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 00000000000..19512a3c25f --- /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); + } + +} 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 00000000000..dc66eadd255 --- /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`. 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 c766522199b..c8499df806c 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 f3dfb832709..6fbf0b7ccdb 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 43cc749d9d9..ca17358491f 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 3b528c04a3d..9efdbeb2fdf 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 82b4042c493..fdd62e4b272 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/AuthorizationEntryPoint.java b/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java new file mode 100644 index 00000000000..f4856e74427 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/AuthorizationEntryPoint.java @@ -0,0 +1,28 @@ +/* + * 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.util.Collection; + +import org.springframework.security.authorization.AuthorizationRequest; +import org.springframework.security.core.GrantedAuthority; + +public interface AuthorizationEntryPoint extends AuthenticationEntryPoint { + + 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 new file mode 100644 index 00000000000..e336045a1ab --- /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.grantableAuthorities(authorizationRequest).isEmpty()) { + 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; + } + +} 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 00000000000..71f5b087984 --- /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; + } + +} 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 dc1a15e89e5..2e6997679e3 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 c44e6484022..8726d75fae3 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");