diff --git a/core/src/main/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManager.java index 8691b0333c..72576169ec 100644 --- a/core/src/main/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManager.java @@ -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 implements Authoriz private final List 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 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 AuthorizationManager anyOf(AllRequiredFactorsAuthorizationManager... 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 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 the type of object being authorized + */ + private static final class AnyOfFactorsAuthorizationManager implements AuthorizationManager { + + private final AllRequiredFactorsAuthorizationManager[] managers; + + AnyOfFactorsAuthorizationManager(AllRequiredFactorsAuthorizationManager[] managers) { + Assert.notEmpty(managers, "managers cannot be empty"); + Assert.noNullElements(managers, "managers cannot contain null elements"); + this.managers = managers; + } + + @Override + public AuthorizationResult authorize(Supplier authentication, T object) { + Set factorErrors = new LinkedHashSet<>(); + for (AllRequiredFactorsAuthorizationManager 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}. * diff --git a/core/src/test/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManagerTests.java index 8d544727d9..302a297bc7 100644 --- a/core/src/test/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManagerTests.java +++ b/core/src/test/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManagerTests.java @@ -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 allFactors = AllRequiredFactorsAuthorizationManager.builder() @@ -219,6 +230,105 @@ class AllRequiredFactorsAuthorizationManagerTests { assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD)); } + @Test + void anyOfWhenOneGrantedThenGranted() { + AllRequiredFactorsAuthorizationManager expiringPasswordAndOtt = AllRequiredFactorsAuthorizationManager + .builder() + .requireFactor(EXPIRING_PASSWORD) + .requireFactor(EXPIRING_OTT) + .build(); + AllRequiredFactorsAuthorizationManager 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 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 passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor(REQUIRED_PASSWORD) + .requireFactor(REQUIRED_OTT) + .build(); + AllRequiredFactorsAuthorizationManager passwordAndExpiringOtt = AllRequiredFactorsAuthorizationManager + .builder() + .requireFactor(REQUIRED_PASSWORD) + .requireFactor(EXPIRING_OTT) + .build(); + FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(REQUIRED_PASSWORD.getAuthority()) + .build(); + AuthorizationManager 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 passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor(REQUIRED_PASSWORD) + .requireFactor(REQUIRED_OTT) + .build(); + AllRequiredFactorsAuthorizationManager passwordOnly = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor(REQUIRED_PASSWORD) + .build(); + AuthorizationManager 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 passwordOnly = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor(REQUIRED_PASSWORD) + .build(); + AllRequiredFactorsAuthorizationManager ottOnly = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor(REQUIRED_OTT) + .build(); + AuthorizationManager 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 manager = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor(REQUIRED_PASSWORD) + .build(); + AuthorizationManager result = AllRequiredFactorsAuthorizationManager.anyOf(manager); + assertThat(result == manager).isTrue(); + } + @Test void setClockWhenNullThenIllegalArgumentException() { AllRequiredFactorsAuthorizationManager allFactors = AllRequiredFactorsAuthorizationManager.builder() diff --git a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc index 6de25b4c78..f4b7c1e776 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc @@ -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 diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 54bb18fb42..4d4a71f4e9 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -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 diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/allfactorsanyof/AnyOfRequiredFactorsConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/allfactorsanyof/AnyOfRequiredFactorsConfiguration.java new file mode 100644 index 0000000000..6a6f572723 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/allfactorsanyof/AnyOfRequiredFactorsConfiguration.java @@ -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 webauthn = AllRequiredFactorsAuthorizationManager + .builder() + .requireFactor((factor) -> factor.webauthnAuthority()) + .build(); + // <2> + AllRequiredFactorsAuthorizationManager passwordAndOtt = AllRequiredFactorsAuthorizationManager + .builder() + .requireFactor((factor) -> factor.passwordAuthority()) + .requireFactor((factor) -> factor.ottAuthority()) + .build(); + // <3> + DefaultAuthorizationManagerFactory 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"); + } + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/allfactorsanyof/AnyOfRequiredFactorsConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/allfactorsanyof/AnyOfRequiredFactorsConfiguration.kt new file mode 100644 index 0000000000..6d79563926 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/allfactorsanyof/AnyOfRequiredFactorsConfiguration.kt @@ -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() + .requireFactor { factor -> factor.webauthnAuthority() } + .build() + // <2> + val passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor { factor -> factor.passwordAuthority() } + .requireFactor { factor -> factor.ottAuthority() } + .build() + // <3> + val mfa = DefaultAuthorizationManagerFactory() + 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") + } + +}