diff --git a/core/src/main/java/org/springframework/security/authorization/ConditionalAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/ConditionalAuthorizationManager.java new file mode 100644 index 0000000000..7daa470602 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/ConditionalAuthorizationManager.java @@ -0,0 +1,154 @@ +/* + * 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.function.Predicate; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An {@link AuthorizationManager} that delegates to one of two + * {@link AuthorizationManager} instances based on a condition evaluated against the + * current {@link Authentication}. + *

+ * When {@link #authorize(Supplier, Object)} is invoked, the condition is evaluated. If + * the {@link Authentication} is non-null and the condition returns {@code true}, the + * {@code whenTrue} manager is used; otherwise the {@code whenFalse} manager is used. + *

+ * This is useful for scenarios such as requiring multi-factor authentication only when + * the user has registered a second factor, or applying different rules based on + * authentication state. + * + * @param the type of object that the authorization check is being performed on + * @author Rob Winch + * @since 7.1 + */ +public final class ConditionalAuthorizationManager implements AuthorizationManager { + + private final Predicate condition; + + private final AuthorizationManager whenTrue; + + private final AuthorizationManager whenFalse; + + /** + * Creates a {@link ConditionalAuthorizationManager} that delegates to + * {@code whenTrue} when the condition holds for the current {@link Authentication}, + * and to {@code whenFalse} otherwise. + * @param condition the condition to evaluate against the {@link Authentication} (must + * not be null) + * @param whenTrue the manager to use when the condition is true (must not be null) + * @param whenFalse the manager to use when the condition is false (must not be null) + */ + private ConditionalAuthorizationManager(Predicate condition, AuthorizationManager whenTrue, + AuthorizationManager whenFalse) { + Assert.notNull(condition, "condition cannot be null"); + Assert.notNull(whenTrue, "whenTrue cannot be null"); + Assert.notNull(whenFalse, "whenFalse cannot be null"); + this.condition = condition; + this.whenTrue = whenTrue; + this.whenFalse = whenFalse; + } + + /** + * Creates a builder for a {@link ConditionalAuthorizationManager} with the given + * condition. + * @param the type of object that the authorization check is being performed on + * @param condition the condition to evaluate against the {@link Authentication} (must + * not be null) + * @return the builder + */ + public static Builder when(Predicate condition) { + Assert.notNull(condition, "condition cannot be null"); + return new Builder<>(condition); + } + + @Override + public @Nullable AuthorizationResult authorize(Supplier authentication, + T object) { + Authentication auth = authentication.get(); + if (auth != null && this.condition.test(auth)) { + return this.whenTrue.authorize(authentication, object); + } + return this.whenFalse.authorize(authentication, object); + } + + /** + * A builder for {@link ConditionalAuthorizationManager}. + * + * @param the type of object that the authorization check is being performed on + * @author Rob Winch + * @since 7.1 + */ + public static final class Builder { + + private final Predicate condition; + + private @Nullable AuthorizationManager whenTrue; + + private @Nullable AuthorizationManager whenFalse; + + private Builder(Predicate condition) { + this.condition = condition; + } + + /** + * Sets the {@link AuthorizationManager} to use when the condition is true. + * @param whenTrue the manager to use when the condition is true (must not be + * null) + * @return the builder + */ + public Builder whenTrue(AuthorizationManager whenTrue) { + Assert.notNull(whenTrue, "whenTrue cannot be null"); + this.whenTrue = whenTrue; + return this; + } + + /** + * Sets the {@link AuthorizationManager} to use when the condition is false. + * Defaults to {@link SingleResultAuthorizationManager#permitAll()} if not set. + * @param whenFalse the manager to use when the condition is false (must not be + * null) + * @return the builder + */ + public Builder whenFalse(AuthorizationManager whenFalse) { + Assert.notNull(whenFalse, "whenFalse cannot be null"); + this.whenFalse = whenFalse; + return this; + } + + /** + * Builds the {@link ConditionalAuthorizationManager}. + * @return the {@link ConditionalAuthorizationManager} + */ + @SuppressWarnings("unchecked") + public ConditionalAuthorizationManager build() { + Assert.state(this.whenTrue != null, "whenTrue is required"); + AuthorizationManager whenFalse = this.whenFalse; + if (whenFalse == null) { + whenFalse = (AuthorizationManager) SingleResultAuthorizationManager.permitAll(); + } + return new ConditionalAuthorizationManager<>(this.condition, this.whenTrue, whenFalse); + } + + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/ConditionalAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/ConditionalAuthorizationManagerTests.java new file mode 100644 index 0000000000..86f03c254c --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/ConditionalAuthorizationManagerTests.java @@ -0,0 +1,131 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link ConditionalAuthorizationManager}. + * + * @author Rob Winch + */ +public class ConditionalAuthorizationManagerTests { + + @Test + void authorizeWhenAuthenticationIsNullThenUsesWhenFalse() { + ConditionalAuthorizationManager manager = ConditionalAuthorizationManager.when((auth) -> true) + .whenTrue(SingleResultAuthorizationManager.denyAll()) + .whenFalse(SingleResultAuthorizationManager.permitAll()) + .build(); + + AuthorizationResult result = manager.authorize(() -> null, new Object()); + + assertThat(result).isNotNull(); + assertThat(result.isGranted()).isTrue(); + } + + @Test + void authorizeWhenConditionIsTrueThenUsesWhenTrue() { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + ConditionalAuthorizationManager manager = ConditionalAuthorizationManager.when((auth) -> true) + .whenTrue(SingleResultAuthorizationManager.permitAll()) + .whenFalse(SingleResultAuthorizationManager.denyAll()) + .build(); + + AuthorizationResult result = manager.authorize(() -> authentication, new Object()); + + assertThat(result).isNotNull(); + assertThat(result.isGranted()).isTrue(); + } + + @Test + void authorizeWhenConditionIsFalseThenUsesWhenFalse() { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + ConditionalAuthorizationManager manager = ConditionalAuthorizationManager.when((auth) -> false) + .whenTrue(SingleResultAuthorizationManager.permitAll()) + .whenFalse(SingleResultAuthorizationManager.denyAll()) + .build(); + + AuthorizationResult result = manager.authorize(() -> authentication, new Object()); + + assertThat(result).isNotNull(); + assertThat(result.isGranted()).isFalse(); + } + + @Test + void authorizeWhenConditionDependsOnAuthenticationThenEvaluatesCorrectly() { + Authentication admin = new TestingAuthenticationToken("admin", "password", "ROLE_ADMIN"); + Authentication user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + ConditionalAuthorizationManager manager = ConditionalAuthorizationManager + .when((auth) -> auth.getAuthorities().stream().anyMatch((a) -> "ROLE_ADMIN".equals(a.getAuthority()))) + .whenTrue(SingleResultAuthorizationManager.permitAll()) + .whenFalse(SingleResultAuthorizationManager.denyAll()) + .build(); + + assertThat(manager.authorize(() -> admin, new Object()).isGranted()).isTrue(); + assertThat(manager.authorize(() -> user, new Object()).isGranted()).isFalse(); + } + + @Test + void buildWhenWhenFalseNotSetThenDefaultsToPermitAll() { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + ConditionalAuthorizationManager manager = ConditionalAuthorizationManager.when((auth) -> false) + .whenTrue(SingleResultAuthorizationManager.denyAll()) + .build(); + + AuthorizationResult result = manager.authorize(() -> authentication, new Object()); + + assertThat(result).isNotNull(); + assertThat(result.isGranted()).isTrue(); + } + + @Test + void whenWhenConditionIsNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ConditionalAuthorizationManager.when(null)) + .withMessage("condition cannot be null"); + } + + @Test + void buildWhenWhenTrueNotSetThenThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> ConditionalAuthorizationManager.when((auth) -> true).build()) + .withMessage("whenTrue is required"); + } + + @Test + void builderWhenWhenTrueIsNullThenThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ConditionalAuthorizationManager.when((auth) -> true).whenTrue(null)) + .withMessage("whenTrue cannot be null"); + } + + @Test + void builderWhenWhenFalseIsNullThenThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ConditionalAuthorizationManager.when((auth) -> true) + .whenTrue(SingleResultAuthorizationManager.permitAll()) + .whenFalse(null)) + .withMessage("whenFalse cannot be null"); + } + +} diff --git a/docs/modules/ROOT/pages/servlet/authorization/architecture.adoc b/docs/modules/ROOT/pages/servlet/authorization/architecture.adoc index 545824f0ba..4c2978edfd 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/architecture.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/architecture.adoc @@ -144,6 +144,16 @@ Another manager is the `AuthenticatedAuthorizationManager`. It can be used to differentiate between anonymous, fully-authenticated and remember-me authenticated users. Many sites allow certain limited access under remember-me authentication, but require a user to confirm their identity by logging in for full access. +[[authz-conditional-authorization-manager]] +==== ConditionalAuthorizationManager +javadoc:org.springframework.security.authorization.ConditionalAuthorizationManager[] delegates to one of two ``AuthorizationManager``s based on a condition evaluated against the current ``Authentication``. +When the condition returns true (and the authentication is non-null), the ``whenTrue`` manager is used; otherwise the ``whenFalse`` manager is used. +Create an instance using the builder returned by `ConditionalAuthorizationManager.when(Predicate)`: set `whenTrue` (required) and optionally `whenFalse` (defaults to permit-all). +This is useful for scenarios such as requiring multi-factor authentication only when the user has registered a second factor. + +.ConditionalAuthorizationManager example +include-code::./ConditionalAuthorizationManagerExample[tag=conditionalAuthorizationManager,indent=0] + [[authz-authorization-manager-factory]] === Creating AuthorizationManager instances diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index e749d90247..2aaa7d0f5f 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -5,6 +5,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] == OAuth 2.0 diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authorization/authzconditionalauthorizationmanager/ConditionalAuthorizationManagerExample.java b/docs/src/test/java/org/springframework/security/docs/servlet/authorization/authzconditionalauthorizationmanager/ConditionalAuthorizationManagerExample.java new file mode 100644 index 0000000000..ca3d0035ef --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authorization/authzconditionalauthorizationmanager/ConditionalAuthorizationManagerExample.java @@ -0,0 +1,33 @@ +package org.springframework.security.docs.servlet.authorization.authzconditionalauthorizationmanager; + +import java.util.function.Predicate; + +import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.ConditionalAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +public class ConditionalAuthorizationManagerExample { + + public void configure(MfaRepository mfaRepository) { + // tag::conditionalAuthorizationManager[] + Predicate whenUserHasMfa = (auth) -> mfaRepository.hasRegisteredMfa(auth.getName()); + AuthorizationManager mfaRequired = AllRequiredFactorsAuthorizationManager + .builder() + .requireFactor((f) -> f.passwordAuthority()) + .requireFactor((f) -> f.webauthnAuthority()) + .build(); + AuthorizationManager manager = ConditionalAuthorizationManager.when(whenUserHasMfa) + .whenTrue(mfaRequired) + .build(); + // end::conditionalAuthorizationManager[] + } + + interface MfaRepository { + + boolean hasRegisteredMfa(String username); + + } + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authorization/authzconditionalauthorizationmanager/ConditionalAuthorizationManagerExample.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authorization/authzconditionalauthorizationmanager/ConditionalAuthorizationManagerExample.kt new file mode 100644 index 0000000000..306cfeebf2 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authorization/authzconditionalauthorizationmanager/ConditionalAuthorizationManagerExample.kt @@ -0,0 +1,26 @@ +package org.springframework.security.kt.docs.servlet.authorization.authzconditionalauthorizationmanager; + +import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager +import org.springframework.security.authorization.ConditionalAuthorizationManager +import org.springframework.security.core.Authentication +import org.springframework.security.web.access.intercept.RequestAuthorizationContext +import java.util.function.Predicate + +class ConditionalAuthorizationManagerExample { + fun configure(mfaRepository: MfaRepository) { + // tag::conditionalAuthorizationManager[] + val whenUserHasMfa = Predicate { auth: Authentication -> mfaRepository.hasRegisteredMfa(auth.name) } + val mfaRequired = AllRequiredFactorsAuthorizationManager.builder() + .requireFactor { f -> f.passwordAuthority() } + .requireFactor { f -> f.webauthnAuthority() } + .build() + val manager = ConditionalAuthorizationManager.`when`(whenUserHasMfa) + .whenTrue(mfaRequired) + .build() + // end::conditionalAuthorizationManager[] + } + + interface MfaRepository { + fun hasRegisteredMfa(username: String?): Boolean + } +}