Add AllRequiredFactorsAuthorizationManager.anyOf

This commit is contained in:
Rob Winch 2026-03-31 15:17:08 -04:00 committed by GitHub
commit 5fe29f9cd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 337 additions and 0 deletions

View File

@ -20,9 +20,11 @@ import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@ -40,6 +42,7 @@ import org.springframework.util.Assert;
* is not expired for each {@link RequiredFactor}.
*
* @author Rob Winch
* @author Evgeniy Cheban
* @since 7.0
* @see AuthorityAuthorizationManager
*/
@ -49,6 +52,27 @@ public final class AllRequiredFactorsAuthorizationManager<T> implements Authoriz
private final List<RequiredFactor> requiredFactors;
/**
* Creates an {@link AuthorizationManager} that grants access if at least one
* {@link AllRequiredFactorsAuthorizationManager} granted. When all managers deny,
* collects the unique {@link RequiredFactorError}s from each manager.
* @param <T> the type of object that is being authorized
* @param managers the {@link AllRequiredFactorsAuthorizationManager}s to use; cannot
* be empty or contain null elements
* @return the {@link AuthorizationManager} to use
* @since 7.1
* @see AuthorizationManagers#anyOf(AuthorizationManager[])
*/
@SafeVarargs
public static <T> AuthorizationManager<T> anyOf(AllRequiredFactorsAuthorizationManager<T>... managers) {
Assert.notEmpty(managers, "managers cannot be empty");
Assert.noNullElements(managers, "managers cannot contain null elements");
if (managers.length == 1) {
return managers[0];
}
return new AnyOfFactorsAuthorizationManager<>(managers);
}
/**
* Creates a new instance.
* @param requiredFactors the authorities that are required.
@ -150,6 +174,38 @@ public final class AllRequiredFactorsAuthorizationManager<T> implements Authoriz
return new Builder<>();
}
/**
* An {@link AuthorizationManager} that grants access if at least one
* {@link AllRequiredFactorsAuthorizationManager} granted. When all deny, collects the
* unique {@link RequiredFactorError}s from each manager.
*
* @param <T> the type of object being authorized
*/
private static final class AnyOfFactorsAuthorizationManager<T> implements AuthorizationManager<T> {
private final AllRequiredFactorsAuthorizationManager<T>[] managers;
AnyOfFactorsAuthorizationManager(AllRequiredFactorsAuthorizationManager<T>[] managers) {
Assert.notEmpty(managers, "managers cannot be empty");
Assert.noNullElements(managers, "managers cannot contain null elements");
this.managers = managers;
}
@Override
public AuthorizationResult authorize(Supplier<? extends @Nullable Authentication> authentication, T object) {
Set<RequiredFactorError> factorErrors = new LinkedHashSet<>();
for (AllRequiredFactorsAuthorizationManager<T> manager : this.managers) {
FactorAuthorizationDecision decision = manager.authorize(authentication, object);
if (decision.isGranted()) {
return decision;
}
factorErrors.addAll(decision.getFactorErrors());
}
return new FactorAuthorizationDecision(List.copyOf(factorErrors));
}
}
/**
* A builder for {@link AllRequiredFactorsAuthorizationManager}.
*

View File

@ -31,11 +31,13 @@ import org.springframework.security.core.authority.FactorGrantedAuthority;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.InstanceOfAssertFactories.type;
/**
* Test {@link AllRequiredFactorsAuthorizationManager}.
*
* @author Rob Winch
* @author Evgeniy Cheban
* @since 7.0
*/
class AllRequiredFactorsAuthorizationManagerTests {
@ -51,6 +53,15 @@ class AllRequiredFactorsAuthorizationManagerTests {
.validDuration(Duration.ofHours(1))
.build();
private static final RequiredFactor REQUIRED_OTT = RequiredFactor
.withAuthority(FactorGrantedAuthority.OTT_AUTHORITY)
.build();
private static final RequiredFactor EXPIRING_OTT = RequiredFactor
.withAuthority(FactorGrantedAuthority.OTT_AUTHORITY)
.validDuration(Duration.ofHours(1))
.build();
@Test
void authorizeWhenGranted() {
AllRequiredFactorsAuthorizationManager<Object> allFactors = AllRequiredFactorsAuthorizationManager.builder()
@ -219,6 +230,105 @@ class AllRequiredFactorsAuthorizationManagerTests {
assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD));
}
@Test
void anyOfWhenOneGrantedThenGranted() {
AllRequiredFactorsAuthorizationManager<Object> expiringPasswordAndOtt = AllRequiredFactorsAuthorizationManager
.builder()
.requireFactor(EXPIRING_PASSWORD)
.requireFactor(EXPIRING_OTT)
.build();
AllRequiredFactorsAuthorizationManager<Object> passwordAndExpiringOtt = AllRequiredFactorsAuthorizationManager
.builder()
.requireFactor(REQUIRED_PASSWORD)
.requireFactor(EXPIRING_OTT)
.build();
FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(EXPIRING_PASSWORD.getAuthority())
.issuedAt(Instant.now().minus(Duration.ofHours(2)))
.build();
FactorGrantedAuthority ottFactor = FactorGrantedAuthority.withAuthority(EXPIRING_OTT.getAuthority()).build();
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(expiringPasswordAndOtt,
passwordAndExpiringOtt);
Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor, ottFactor);
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
assertThat(result).isNotNull();
assertThat(result.isGranted()).isTrue();
}
@Test
void anyOfWhenSameAuthorityDifferentValidDurationThenBothErrorsReturned() {
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder()
.requireFactor(REQUIRED_PASSWORD)
.requireFactor(REQUIRED_OTT)
.build();
AllRequiredFactorsAuthorizationManager<Object> passwordAndExpiringOtt = AllRequiredFactorsAuthorizationManager
.builder()
.requireFactor(REQUIRED_PASSWORD)
.requireFactor(EXPIRING_OTT)
.build();
FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(REQUIRED_PASSWORD.getAuthority())
.build();
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(passwordAndOtt,
passwordAndExpiringOtt);
Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
assertThat(result).asInstanceOf(type(FactorAuthorizationDecision.class)).satisfies((decision) -> {
assertThat(decision.isGranted()).isFalse();
assertThat(decision.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_OTT),
RequiredFactorError.createMissing(EXPIRING_OTT));
});
}
@Test
void anyOfWhenIdenticalErrorInMultipleManagersThenDeduplicated() {
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder()
.requireFactor(REQUIRED_PASSWORD)
.requireFactor(REQUIRED_OTT)
.build();
AllRequiredFactorsAuthorizationManager<Object> passwordOnly = AllRequiredFactorsAuthorizationManager.builder()
.requireFactor(REQUIRED_PASSWORD)
.build();
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(passwordAndOtt, passwordOnly);
Authentication authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER");
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
assertThat(result).asInstanceOf(type(FactorAuthorizationDecision.class)).satisfies((decision) -> {
assertThat(decision.isGranted()).isFalse();
assertThat(decision.getFactorErrors()).containsOnly(RequiredFactorError.createMissing(REQUIRED_PASSWORD),
RequiredFactorError.createMissing(REQUIRED_OTT));
});
}
@Test
void anyOfWhenDeniedThenErrorsRetainedInManagerOrder() {
AllRequiredFactorsAuthorizationManager<Object> passwordOnly = AllRequiredFactorsAuthorizationManager.builder()
.requireFactor(REQUIRED_PASSWORD)
.build();
AllRequiredFactorsAuthorizationManager<Object> ottOnly = AllRequiredFactorsAuthorizationManager.builder()
.requireFactor(REQUIRED_OTT)
.build();
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(passwordOnly, ottOnly);
Authentication authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER");
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
assertThat(result).asInstanceOf(type(FactorAuthorizationDecision.class)).satisfies((decision) -> {
assertThat(decision.isGranted()).isFalse();
assertThat(decision.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD),
RequiredFactorError.createMissing(REQUIRED_OTT));
});
}
@Test
void anyOfWhenEmptyManagersThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> AllRequiredFactorsAuthorizationManager.anyOf());
}
@Test
void anyOfWhenSingleManagerThenReturnsSameInstance() {
AllRequiredFactorsAuthorizationManager<Object> manager = AllRequiredFactorsAuthorizationManager.builder()
.requireFactor(REQUIRED_PASSWORD)
.build();
AuthorizationManager<Object> result = AllRequiredFactorsAuthorizationManager.anyOf(manager);
assertThat(result == manager).isTrue();
}
@Test
void setClockWhenNullThenIllegalArgumentException() {
AllRequiredFactorsAuthorizationManager<Object> allFactors = AllRequiredFactorsAuthorizationManager.builder()

View File

@ -125,6 +125,23 @@ include-code::./ValidDurationConfiguration[tag=httpSecurity,indent=0]
<5> Otherwise, authentication is required, but it does not care if it is a password or how long ago authentication occurred
<6> Set up the authentication mechanisms that can provide the required factors.
[[all-factors-anyof]]
== AllRequiredFactorsAuthorizationManager.anyOf
In the previous examples, access requires satisfying that the user has authenticated with all factors.
There are times when an application wants to allow users to satisfy one of several different combinations of factors.
javadoc:org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager#anyOf(AllRequiredFactorsAuthorizationManager...)[AllRequiredFactorsAuthorizationManager.anyOf] grants access if at least one of the provided combinations of factors is satisfied.
Consider a scenario where a user can authenticate with WebAuthn alone, or with both a password and a one-time token.
include-code::./AnyOfRequiredFactorsConfiguration[tag=httpSecurity,indent=0]
<1> Require WebAuthn
<2> Require both a password and a one-time token
<3> Combine the combinations of factors with `anyOf`, granting access if either is satisfied
<4> URLs that begin with `/protected/**` require the user to satisfy either combination of factors
<5> All other requests require only authentication
<6> Set up the authentication mechanisms that can provide the required factors
[[programmatic-mfa]]
== Programmatic MFA

View File

@ -4,6 +4,7 @@
== Core
* https://github.com/spring-projects/spring-security/pull/18634[gh-18634] - Added javadoc:org.springframework.security.util.matcher.InetAddressMatcher[]
* https://github.com/spring-projects/spring-security/issues/18960[gh-18960] - Added xref:servlet/authentication/mfa.adoc#all-factors-anyof[AllRequiredFactorsAuthorizationManager.anyOf]
== Web
* https://github.com/spring-projects/spring-security/issues/18755[gh-18755] - Include `charset` in `WWW-Authenticate` header

View File

@ -0,0 +1,76 @@
package org.springframework.security.docs.servlet.authentication.allfactorsanyof;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager;
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class AnyOfRequiredFactorsConfiguration {
// tag::httpSecurity[]
@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
// @formatter:off
// <1>
AllRequiredFactorsAuthorizationManager<Object> webauthn = AllRequiredFactorsAuthorizationManager
.<Object>builder()
.requireFactor((factor) -> factor.webauthnAuthority())
.build();
// <2>
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt = AllRequiredFactorsAuthorizationManager
.<Object>builder()
.requireFactor((factor) -> factor.passwordAuthority())
.requireFactor((factor) -> factor.ottAuthority())
.build();
// <3>
DefaultAuthorizationManagerFactory<Object> mfa = new DefaultAuthorizationManagerFactory<>();
mfa.setAdditionalAuthorization(AllRequiredFactorsAuthorizationManager.anyOf(webauthn, passwordAndOtt));
http
.authorizeHttpRequests((authorize) -> authorize
// <4>
.requestMatchers("/protected/**").access(mfa.authenticated())
// <5>
.anyRequest().authenticated()
)
// <6>
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults())
.webAuthn((webAuthn) -> webAuthn
.rpName("Spring Security")
.rpId("example.com")
.allowedOrigins("https://example.com")
);
// @formatter:on
return http.build();
}
// end::httpSecurity[]
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
);
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
}

View File

@ -0,0 +1,77 @@
package org.springframework.security.kt.docs.servlet.authentication.allfactorsanyof
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
internal class AnyOfRequiredFactorsConfiguration {
// tag::httpSecurity[]
@Bean
@Throws(Exception::class)
fun springSecurity(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
// <1>
val webauthn = AllRequiredFactorsAuthorizationManager.builder<Any>()
.requireFactor { factor -> factor.webauthnAuthority() }
.build()
// <2>
val passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder<Any>()
.requireFactor { factor -> factor.passwordAuthority() }
.requireFactor { factor -> factor.ottAuthority() }
.build()
// <3>
val mfa = DefaultAuthorizationManagerFactory<Any>()
mfa.setAdditionalAuthorization(AllRequiredFactorsAuthorizationManager.anyOf(webauthn, passwordAndOtt))
http {
authorizeHttpRequests {
// <4>
authorize("/protected/**", mfa.authenticated())
// <5>
authorize(anyRequest, authenticated)
}
// <6>
formLogin { }
oneTimeTokenLogin { }
webAuthn {
rpName = "Spring Security"
rpId = "example.com"
allowedOrigins = setOf("https://example.com")
}
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
@Suppress("DEPRECATION")
@Bean
fun userDetailsService(): UserDetailsService {
return InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
)
}
@Bean
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
}
}