Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
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;
Expand All @@ -40,6 +42,7 @@
* is not expired for each {@link RequiredFactor}.
*
* @author Rob Winch
* @author Evgeniy Cheban
* @since 7.0
* @see AuthorityAuthorizationManager
*/
Expand All @@ -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[])
*/
Comment on lines +55 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would you consider adding a @see tag in the Javadoc of AuthorizationManagers#anyOf(AuthorizationManager...) to remind users of the existence of this method?

@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.
Expand Down Expand Up @@ -150,6 +174,38 @@ public static <T> Builder<T> builder() {
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}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@
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 {
Expand All @@ -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()
Expand Down Expand Up @@ -219,6 +230,105 @@ void authorizeWhenDifferentFactorGrantedAuthorityThenMissing() {
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()
Expand Down
17 changes: 17 additions & 0 deletions docs/modules/ROOT/pages/servlet/authentication/mfa.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/whats-new.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}

}
Loading
Loading