diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationManagerFactories.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationManagerFactories.java index 207a2ccc79..b48bf66709 100644 --- a/core/src/main/java/org/springframework/security/authorization/AuthorizationManagerFactories.java +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationManagerFactories.java @@ -17,6 +17,13 @@ package org.springframework.security.authorization; import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; /** * Creates common {@link AuthorizationManagerFactory} instances. @@ -56,6 +63,38 @@ public final class AuthorizationManagerFactories { private final AllRequiredFactorsAuthorizationManager.Builder factors = AllRequiredFactorsAuthorizationManager .builder(); + private @Nullable Predicate whenCondition; + + /** + * Apply the required factors only when the given condition is true for the + * current {@link Authentication}. When the condition is false, no additional + * factors are required (equivalent to permit-all for the additional + * authorization). Implemented using + * {@link ConditionalAuthorizationManager#when(java.util.function.Predicate)}. + * @param condition the condition to evaluate (must not be null) + * @return the {@link AdditionalRequiredFactorsBuilder} to further customize + * @since 7.1 + */ + public AdditionalRequiredFactorsBuilder when(Predicate condition) { + Assert.notNull(condition, "condition cannot be null"); + this.whenCondition = condition; + return this; + } + + /** + * Customize the condition that determines if the required factors are evaluated. + * @param condition a function that takes the current condition and returns the + * new condition + * @return the {@link AdditionalRequiredFactorsBuilder} to further customize + * @since 7.1 + */ + public AdditionalRequiredFactorsBuilder withWhen( + Function<@Nullable Predicate, @Nullable Predicate> condition) { + Assert.notNull(condition, "condition cannot be null"); + this.whenCondition = condition.apply(this.whenCondition); + return this; + } + /** * Add additional authorities that will be required. * @param additionalAuthorities the additional authorities. @@ -89,7 +128,12 @@ public final class AuthorizationManagerFactories { */ public DefaultAuthorizationManagerFactory build() { DefaultAuthorizationManagerFactory result = new DefaultAuthorizationManagerFactory<>(); - AllRequiredFactorsAuthorizationManager additionalChecks = this.factors.build(); + AuthorizationManager additionalChecks = this.factors.build(); + if (this.whenCondition != null) { + additionalChecks = ConditionalAuthorizationManager.when(this.whenCondition) + .whenTrue(additionalChecks) + .build(); + } result.setAdditionalAuthorization(additionalChecks); return result; } diff --git a/core/src/test/java/org/springframework/security/authorization/AuthorizationManagerFactoryTests.java b/core/src/test/java/org/springframework/security/authorization/AuthorizationManagerFactoryTests.java index 4a00911c5f..230c166bae 100644 --- a/core/src/test/java/org/springframework/security/authorization/AuthorizationManagerFactoryTests.java +++ b/core/src/test/java/org/springframework/security/authorization/AuthorizationManagerFactoryTests.java @@ -19,8 +19,10 @@ package org.springframework.security.authorization; import org.junit.jupiter.api.Test; import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.authentication.TestingAuthenticationToken; 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.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -311,6 +313,59 @@ public class AuthorizationManagerFactoryTests { .isTrue(); } + @Test + public void builderWhenWhenConditionThenAdditionalFactorsRequiredOnlyWhenConditionTrue() { + AuthorizationManagerFactory factory = AuthorizationManagerFactories.multiFactor() + .requireFactors("ROLE_ADMIN") + .when((auth) -> "admin".equals(auth.getName())) + .build(); + // When condition is true (admin user), ROLE_ADMIN is required in addition to + // hasRole("USER") + assertThat(factory.hasRole("USER").authorize(() -> TestAuthentication.authenticatedAdmin(), "").isGranted()) + .isTrue(); + // When condition is false (non-admin user), additional factors are not required + assertUserGranted(factory.hasRole("USER")); + } + + @Test + public void builderWhenWhenConditionFalseThenUserWithoutRequiredFactorGranted() { + AuthorizationManagerFactory factory = AuthorizationManagerFactories.multiFactor() + .requireFactors("ROLE_ADMIN") + .when((auth) -> "admin".equals(auth.getName())) + .build(); + // Non-admin user does not need ROLE_ADMIN for hasRole("USER") + assertThat(factory.hasRole("USER").authorize(() -> TestAuthentication.authenticatedUser(), "").isGranted()) + .isTrue(); + } + + @Test + public void builderWhenWithWhenConditionThenConditionIsCustomized() { + AuthorizationManagerFactory factory = AuthorizationManagerFactories.multiFactor() + .requireFactors("ROLE_ADMIN") + .when((auth) -> "admin".equals(auth.getName())) + .withWhen((current) -> (auth) -> current != null && current.test(auth) && auth.isAuthenticated()) + .build(); + // When condition is true (admin user and authenticated), ROLE_ADMIN is required + assertThat(factory.hasRole("USER").authorize(() -> TestAuthentication.authenticatedAdmin(), "").isGranted()) + .isTrue(); + // When condition is false (admin user but not authenticated), additional factors + // are not required + TestingAuthenticationToken unauthenticatedAdmin = new TestingAuthenticationToken("admin", "password", + "ROLE_USER"); + unauthenticatedAdmin.setAuthenticated(false); + assertThat(factory.hasRole("USER").authorize(() -> unauthenticatedAdmin, "").isGranted()).isTrue(); + // When condition is false (non-admin user), additional factors are not required + assertUserGranted(factory.hasRole("USER")); + } + + @Test + public void builderWhenWithWhenNullThenIllegalArgumentException() { + AuthorizationManagerFactories.AdditionalRequiredFactorsBuilder builder = AuthorizationManagerFactories + .multiFactor(); + assertThatIllegalArgumentException().isThrownBy(() -> builder.withWhen(null)) + .withMessage("condition cannot be null"); + } + private void assertUserGranted(AuthorizationManager manager) { assertThat(manager.authorize(() -> TestAuthentication.authenticatedUser(), "").isGranted()).isTrue(); } diff --git a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc index 404c41a275..bce3d43237 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc @@ -108,23 +108,20 @@ include-code::./ValidDurationConfiguration[tag=httpSecurity,indent=0] In our previous examples, MFA is a static decision per request. There are times when we might want to require MFA for some users, but not others. -Determining if MFA is enabled per user can be achieved by creating a custom `AuthorizationManager` that conditionally requires factors based upon the `Authentication`. +Determining if MFA is enabled per user can be achieved by using `AuthorizationManagerFactories.multiFactor().when` to conditionally require factors based upon the `Authentication`. +This is implemented using xref:servlet/authorization/architecture.adoc#authz-conditional-authorization-manager[`ConditionalAuthorizationManager`]. -include-code::./AdminMfaAuthorizationManagerConfiguration[tag=authorizationManager,indent=0] -<1> MFA is required for the user with the username `admin` -<2> Otherwise, MFA is not required - -To enable the MFA rules globally, we can publish an `AuthorizationManagerFactory` Bean. +To enable the conditional MFA rules globally, we can publish an `AuthorizationManagerFactory` Bean. include-code::./AdminMfaAuthorizationManagerConfiguration[tag=authorizationManagerFactory,indent=0] -<1> Inject the custom `AuthorizationManager` as the javadoc:org.springframework.security.authorization.DefaultAuthorizationManagerFactory#setAdditionalAuthorization(org.springframework.security.authorization.AuthorizationManager)[DefaultAuthorization.additionalAuthorization]. -This instructs `DefaultAuthorizationManagerFactory` that any authorization rule should apply our custom `AuthorizationManager` along with any authorization requirements defined by the application (e.g. `hasRole("ADMIN")`). -<2> Publish `DefaultAuthorizationManagerFactory` as a Bean, so it is used globally +<1> Require `FACTOR_OTT` and `FACTOR_PASSWORD` +<2> Only apply the requirement if the username is `admin`. Otherwise, MFA is not required. +<3> Return the `AuthorizationManagerFactory` using `.build()`. Since it is published as a Bean, it is used globally. This should feel very similar to our previous example in xref:./mfa.adoc#authorization-manager-factory[]. -The difference is that in the previous example, the `AuthorizationManagerFactories` is setting `DefaultAuthorization.additionalAuthorization` with a built in `AuthorizationManager` that always requires the same authorities. +The difference is that in the previous example, the `AuthorizationManagerFactories` creates an `AuthorizationManager` that always requires the same authorities. -We can now define our authorization rules which are combined with `AdminMfaAuthorizationManager`. +We can now define our authorization rules which are combined with `AuthorizationManagerFactory`. include-code::./AdminMfaAuthorizationManagerConfiguration[tag=httpSecurity,indent=0] <1> URLs that begin with `/admin/**` require `ROLE_ADMIN`. @@ -138,7 +135,7 @@ If we preferred, we could change our logic to enable MFA based upon the roles ra [[raam-mfa]] == RequiredAuthoritiesAuthorizationManager -We've demonstrated how we can dynamically determine the authorities for a particular user in xref:./mfa.adoc#programmatic-mfa[] using a custom `AuthorizationManager`. +We've demonstrated how we can dynamically determine the authorities for a particular user in xref:./mfa.adoc#programmatic-mfa[] using `AuthorizationManagerFactories.multiFactor().when`. However, this is such a common scenario that Spring Security provides built in support using javadoc:org.springframework.security.authorization.RequiredAuthoritiesAuthorizationManager[] and javadoc:org.springframework.security.authorization.RequiredAuthoritiesRepository[]. Let's implement the same requirement that we did in xref:./mfa.adoc#programmatic-mfa[] using the built-in support. @@ -168,7 +165,7 @@ Our example uses an in memory mapping of usernames to the additional required au For more dynamic use cases that can be determined by the username, a custom implementation of javadoc:org.springframework.security.authorization.RequiredAuthoritiesRepository[] can be created. Possible examples would be looking up if a user has enabled MFA in an explicit setting, determining if a user has registered a passkey, etc. -For cases that need to determine MFA based upon the `Authentication`, a custom `AuthorizationManger` can be used as demonstrated in xref:./mfa.adoc#programmatic-mfa[]. +For cases that need to determine MFA based upon the `Authentication`, `AuthorizationManagerFactories.multiFactor().when` can be used as demonstrated in xref:./mfa.adoc#programmatic-mfa[]. [[hasallauthorities]] diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 2aaa7d0f5f..a89ff9aacc 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -6,6 +6,7 @@ * https://github.com/spring-projects/spring-security/pull/18634[gh-18634] - Added javadoc:org.springframework.security.web.util.matcher.InetAddressMatcher[] * https://github.com/spring-projects/spring-security/issues/18755[gh-18755] - Include `charset` in `WWW-Authenticate` header * Added xref:servlet/authorization/architecture.adoc#authz-conditional-authorization-manager[ConditionalAuthorizationManager] +* Added `when` and `withWhen` conditions to `AuthorizationManagerFactories.multiFactor()` for xref:servlet/authentication/mfa.adoc#programmatic-mfa[Programmatic MFA] == OAuth 2.0 diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.java index 911121c775..1eb41a47ad 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.java @@ -1,17 +1,9 @@ package org.springframework.security.docs.servlet.authentication.programmaticmfa; -import java.util.function.Supplier; - -import org.jspecify.annotations.Nullable; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagerFactories; import org.springframework.security.authorization.AuthorizationManagerFactory; -import org.springframework.security.authorization.AuthorizationResult; -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; @@ -23,7 +15,6 @@ 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; -import org.springframework.stereotype.Component; @EnableWebSecurity @Configuration(proxyBeanMethods = false) @@ -46,36 +37,16 @@ class AdminMfaAuthorizationManagerConfiguration { } // end::httpSecurity[] - // tag::authorizationManager[] - @Component - class AdminMfaAuthorizationManager implements AuthorizationManager { - @Override - public AuthorizationResult authorize(Supplier authentication, Object context) { - if ("admin".equals(authentication.get().getName())) { - AuthorizationManager admins = - AllAuthoritiesAuthorizationManager.hasAllAuthorities( - FactorGrantedAuthority.OTT_AUTHORITY, - FactorGrantedAuthority.PASSWORD_AUTHORITY - ); - // <1> - return admins.authorize(authentication, context); - } else { - // <2> - return new AuthorizationDecision(true); - } - } - } - // end::authorizationManager[] - // tag::authorizationManagerFactory[] @Bean - AuthorizationManagerFactory authorizationManagerFactory( - AdminMfaAuthorizationManager admins) { - DefaultAuthorizationManagerFactory defaults = new DefaultAuthorizationManagerFactory<>(); - // <1> - defaults.setAdditionalAuthorization(admins); - // <2> - return defaults; + AuthorizationManagerFactory authorizationManagerFactory() { + // <3> + return AuthorizationManagerFactories.multiFactor() + // <1> + .requireFactors(FactorGrantedAuthority.OTT_AUTHORITY, FactorGrantedAuthority.PASSWORD_AUTHORITY) + // <2> + .when((auth) -> "admin".equals(auth.getName())) + .build(); } // end::authorizationManagerFactory[] diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.kt index ffed6077f0..dd00f0327c 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/programmaticmfa/AdminMfaAuthorizationManagerConfiguration.kt @@ -14,8 +14,6 @@ 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 -import org.springframework.stereotype.Component -import java.util.function.Supplier @EnableWebSecurity @Configuration(proxyBeanMethods = false) @@ -40,34 +38,16 @@ internal class AdminMfaAuthorizationManagerConfiguration { } // end::httpSecurity[] - // tag::authorizationManager[] - @Component - internal open class AdminMfaAuthorizationManager : AuthorizationManager { - override fun authorize( - authentication: Supplier, context: Any): AuthorizationResult { - return if ("admin" == authentication.get().name) { - var admins = - AllAuthoritiesAuthorizationManager.hasAllAuthorities( - FactorGrantedAuthority.OTT_AUTHORITY, - FactorGrantedAuthority.PASSWORD_AUTHORITY) - // <1> - admins.authorize(authentication, context) - } else { - // <2> - AuthorizationDecision(true) - } - } - } - // end::authorizationManager[] - // tag::authorizationManagerFactory[] @Bean - fun authorizationManagerFactory(admins: AdminMfaAuthorizationManager): AuthorizationManagerFactory { - val defaults = DefaultAuthorizationManagerFactory() - // <1> - defaults.setAdditionalAuthorization(admins) - // <2> - return defaults + fun authorizationManagerFactory(): AuthorizationManagerFactory { + // <3> + return AuthorizationManagerFactories.multiFactor() + // <1> + .requireFactors(FactorGrantedAuthority.OTT_AUTHORITY, FactorGrantedAuthority.PASSWORD_AUTHORITY) + // <2> + .`when` { auth -> "admin" == auth.name } + .build() } // end::authorizationManagerFactory[]