Explorar el Código

Add AllFactorsAuthorizationManager

Closes gh-17997
Rob Winch hace 1 semana
padre
commit
d1ff983c11

+ 196 - 0
core/src/main/java/org/springframework/security/authorization/AllFactorsAuthorizationManager.java

@@ -0,0 +1,196 @@
+/*
+ * 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.time.Clock;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.FactorGrantedAuthority;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthorizationManager} that determines if the current user is authorized by
+ * evaluating if the {@link Authentication} contains a {@link FactorGrantedAuthority} that
+ * is not expired for each {@link RequiredFactor}.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ * @see AuthorityAuthorizationManager
+ */
+public final class AllFactorsAuthorizationManager<T> implements AuthorizationManager<T> {
+
+	private Clock clock = Clock.systemUTC();
+
+	private final List<RequiredFactor> requiredFactors;
+
+	/**
+	 * Creates a new instance.
+	 * @param requiredFactors the authorities that are required.
+	 */
+	private AllFactorsAuthorizationManager(List<RequiredFactor> requiredFactors) {
+		Assert.notEmpty(requiredFactors, "requiredFactors cannot be empty");
+		Assert.noNullElements(requiredFactors, "requiredFactors must not contain null elements");
+		this.requiredFactors = Collections.unmodifiableList(requiredFactors);
+	}
+
+	/**
+	 * Sets the {@link Clock} to use.
+	 * @param clock the {@link Clock} to use. Cannot be null.
+	 */
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock cannot be null");
+		this.clock = clock;
+	}
+
+	/**
+	 * For each {@link RequiredFactor} finds the first
+	 * {@link FactorGrantedAuthority#getAuthority()} that matches the
+	 * {@link RequiredFactor#getAuthority()}. The
+	 * {@link FactorGrantedAuthority#getIssuedAt()} must be more recent than
+	 * {@link RequiredFactor#getValidDuration()} (if non-null).
+	 * @param authentication the {@link Supplier} of the {@link Authentication} to check
+	 * @param object the object to check authorization on (not used).
+	 * @return an {@link FactorAuthorizationDecision}
+	 */
+	@Override
+	public FactorAuthorizationDecision authorize(Supplier<? extends @Nullable Authentication> authentication,
+			T object) {
+		List<FactorGrantedAuthority> currentFactorAuthorities = getFactorGrantedAuthorities(authentication.get());
+		List<RequiredFactorError> factorErrors = this.requiredFactors.stream()
+			.map((factor) -> requiredFactorError(factor, currentFactorAuthorities))
+			.filter(Objects::nonNull)
+			.toList();
+		return new FactorAuthorizationDecision(factorErrors);
+	}
+
+	/**
+	 * Given the {@link RequiredFactor} and the current {@link FactorGrantedAuthority}
+	 * instances, returns {@link RequiredFactor} or null if granted.
+	 * @param requiredFactor the {@link RequiredFactor} to check.
+	 * @param currentFactors the current user's {@link FactorGrantedAuthority}.
+	 * @return the {@link RequiredFactor} or null if granted.
+	 */
+	private @Nullable RequiredFactorError requiredFactorError(RequiredFactor requiredFactor,
+			List<FactorGrantedAuthority> currentFactors) {
+		Optional<FactorGrantedAuthority> matchingAuthority = currentFactors.stream()
+			.filter((authority) -> authority.getAuthority().equals(requiredFactor.getAuthority()))
+			.findFirst();
+		if (!matchingAuthority.isPresent()) {
+			return RequiredFactorError.createMissing(requiredFactor);
+		}
+		return matchingAuthority.map((authority) -> {
+			if (requiredFactor.getValidDuration() == null) {
+				// granted (only requires authority to match)
+				return null;
+			}
+			Instant now = this.clock.instant();
+			Instant expiresAt = authority.getIssuedAt().plus(requiredFactor.getValidDuration());
+			if (now.isBefore(expiresAt)) {
+				// granted
+				return null;
+			}
+			// denied (expired)
+			return RequiredFactorError.createExpired(requiredFactor);
+		}).orElse(null);
+	}
+
+	/**
+	 * Extracts all of the {@link FactorGrantedAuthority} instances from
+	 * {@link Authentication#getAuthorities()}. If {@link Authentication} is null, or
+	 * {@link Authentication#isAuthenticated()} is false, then an empty {@link List} is
+	 * returned.
+	 * @param authentication the {@link Authentication} (possibly null).
+	 * @return all of the {@link FactorGrantedAuthority} instances from
+	 * {@link Authentication#getAuthorities()}.
+	 */
+	private List<FactorGrantedAuthority> getFactorGrantedAuthorities(@Nullable Authentication authentication) {
+		if (authentication == null || !authentication.isAuthenticated()) {
+			return Collections.emptyList();
+		}
+		// @formatter:off
+		return authentication.getAuthorities().stream()
+			.filter(FactorGrantedAuthority.class::isInstance)
+			.map(FactorGrantedAuthority.class::cast)
+			.collect(Collectors.toList());
+		// @formatter:on
+	}
+
+	/**
+	 * Creates a new {@link Builder}
+	 * @return
+	 */
+	public static Builder builder() {
+		return new Builder();
+	}
+
+	/**
+	 * A builder for {@link AllFactorsAuthorizationManager}.
+	 *
+	 * @author Rob Winch
+	 * @since 7.0
+	 */
+	public static final class Builder {
+
+		private List<RequiredFactor> requiredFactors = new ArrayList<>();
+
+		/**
+		 * Allows the user to consume the {@link RequiredFactor.Builder} that is passed in
+		 * and then adds the result to the {@link #requiredFactor(RequiredFactor)}.
+		 * @param requiredFactor the {@link Consumer} to invoke.
+		 * @return the builder.
+		 */
+		public Builder requiredFactor(Consumer<RequiredFactor.Builder> requiredFactor) {
+			Assert.notNull(requiredFactor, "requiredFactor cannot be null");
+			RequiredFactor.Builder builder = RequiredFactor.builder();
+			requiredFactor.accept(builder);
+			return requiredFactor(builder.build());
+		}
+
+		/**
+		 * The {@link RequiredFactor} to add.
+		 * @param requiredFactor the requiredFactor to add. Cannot be null.
+		 * @return the builder.
+		 */
+		public Builder requiredFactor(RequiredFactor requiredFactor) {
+			Assert.notNull(requiredFactor, "requiredFactor cannot be null");
+			this.requiredFactors.add(requiredFactor);
+			return this;
+		}
+
+		/**
+		 * Builds the {@link AllFactorsAuthorizationManager}.
+		 * @param <T> the type.
+		 * @return the {@link AllFactorsAuthorizationManager}
+		 */
+		public <T> AllFactorsAuthorizationManager<T> build() {
+			return new AllFactorsAuthorizationManager<T>(this.requiredFactors);
+		}
+
+	}
+
+}

+ 62 - 0
core/src/main/java/org/springframework/security/authorization/FactorAuthorizationDecision.java

@@ -0,0 +1,62 @@
+/*
+ * 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.Collections;
+import java.util.List;
+
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthorizationResult} that contains {@link RequiredFactorError}.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ */
+public class FactorAuthorizationDecision implements AuthorizationResult {
+
+	private final List<RequiredFactorError> factorErrors;
+
+	/**
+	 * Creates a new instance.
+	 * @param factorErrors the {@link RequiredFactorError}. If empty, {@link #isGranted()}
+	 * returns true. Cannot be null or contain empty values.
+	 */
+	public FactorAuthorizationDecision(List<RequiredFactorError> factorErrors) {
+		Assert.notNull(factorErrors, "factorErrors cannot be null");
+		Assert.noNullElements(factorErrors, "factorErrors must not contain null elements");
+		this.factorErrors = Collections.unmodifiableList(factorErrors);
+	}
+
+	/**
+	 * The specified {@link RequiredFactorError}s
+	 * @return the errors. Cannot be null or contain null values.
+	 */
+	public List<RequiredFactorError> getFactorErrors() {
+		return this.factorErrors;
+	}
+
+	/**
+	 * Returns {@code getFactorErrors().isEmpty()}.
+	 * @return
+	 */
+	@Override
+	public boolean isGranted() {
+		return this.factorErrors.isEmpty();
+	}
+
+}

+ 142 - 0
core/src/main/java/org/springframework/security/authorization/RequiredFactor.java

@@ -0,0 +1,142 @@
+/*
+ * 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.time.Duration;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.security.core.authority.FactorGrantedAuthority;
+import org.springframework.util.Assert;
+
+/**
+ * The requirements for an {@link FactorGrantedAuthority} to be considered valid.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ */
+public final class RequiredFactor {
+
+	private final String authority;
+
+	private final @Nullable Duration validDuration;
+
+	private RequiredFactor(String authority, @Nullable Duration validDuration) {
+		Assert.notNull(authority, "authority cannot be null");
+		this.authority = authority;
+		this.validDuration = validDuration;
+	}
+
+	/**
+	 * The {@link FactorGrantedAuthority#getAuthority()}.
+	 * @return the authority.
+	 */
+	public String getAuthority() {
+		return this.authority;
+	}
+
+	/**
+	 * How long the
+	 * {@link org.springframework.security.core.authority.FactorGrantedAuthority} is valid
+	 * for.
+	 * @return
+	 */
+	public @Nullable Duration getValidDuration() {
+		return this.validDuration;
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (!(o instanceof RequiredFactor that)) {
+			return false;
+		}
+		return Objects.equals(this.authority, that.authority) && Objects.equals(this.validDuration, that.validDuration);
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.authority, this.validDuration);
+	}
+
+	@Override
+	public String toString() {
+		return "RequiredFactor [authority=" + this.authority + ", validDuration=" + this.validDuration + "]";
+	}
+
+	/**
+	 * Creates a {@link Builder} with the specified authority.
+	 * @param authority the authority.
+	 * @return the builder.
+	 */
+	public static Builder withAuthority(String authority) {
+		return builder().authority(authority);
+	}
+
+	/**
+	 * Creates a new {@link Builder}.
+	 * @return
+	 */
+	public static Builder builder() {
+		return new Builder();
+	}
+
+	/**
+	 * A builder for {@link RequiredFactor}.
+	 *
+	 * @author Rob Winch
+	 * @since 7.0
+	 */
+	public static class Builder {
+
+		private @Nullable String authority;
+
+		private @Nullable Duration validDuration;
+
+		/**
+		 * Sets the required authority.
+		 * @param authority the authority.
+		 * @return the builder.
+		 */
+		public Builder authority(String authority) {
+			this.authority = authority;
+			return this;
+		}
+
+		/**
+		 * Sets the optional {@link Duration} of time that the {@link RequiredFactor} is
+		 * valid for.
+		 * @param validDuration the {@link Duration}.
+		 * @return
+		 */
+		public Builder validDuration(Duration validDuration) {
+			this.validDuration = validDuration;
+			return this;
+		}
+
+		/**
+		 * Builds a new instance.
+		 * @return
+		 */
+		public RequiredFactor build() {
+			Assert.notNull(this.authority, "authority cannot be null");
+			return new RequiredFactor(this.authority, this.validDuration);
+		}
+
+	}
+
+}

+ 118 - 0
core/src/main/java/org/springframework/security/authorization/RequiredFactorError.java

@@ -0,0 +1,118 @@
+/*
+ * 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.Objects;
+
+import org.springframework.security.core.authority.FactorGrantedAuthority;
+import org.springframework.util.Assert;
+
+/**
+ * An error when the requirements of {@link RequiredFactor} are not met.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ */
+public class RequiredFactorError {
+
+	private final RequiredFactor requiredFactor;
+
+	private final Reason reason;
+
+	RequiredFactorError(RequiredFactor requiredFactor, Reason reason) {
+		Assert.notNull(requiredFactor, "RequiredFactor must not be null");
+		Assert.notNull(reason, "Reason must not be null");
+		if (reason == Reason.EXPIRED && requiredFactor.getValidDuration() == null) {
+			throw new IllegalArgumentException(
+					"If expired, RequiredFactor.getValidDuration() must not be null. Got " + requiredFactor);
+		}
+		this.requiredFactor = requiredFactor;
+		this.reason = reason;
+	}
+
+	public RequiredFactor getRequiredFactor() {
+		return this.requiredFactor;
+	}
+
+	/**
+	 * True if not {@link #isMissing()} but was older than the
+	 * {@link RequiredFactor#getValidDuration()}.
+	 * @return true if expired, else false
+	 */
+	public boolean isExpired() {
+		return this.reason == Reason.EXPIRED;
+	}
+
+	/**
+	 * True if no {@link FactorGrantedAuthority#getAuthority()} on the
+	 * {@link org.springframework.security.core.Authentication} matched
+	 * {@link RequiredFactor#getAuthority()}.
+	 * @return true if missing, else false.
+	 */
+	public boolean isMissing() {
+		return this.reason == Reason.MISSING;
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (o == null || getClass() != o.getClass()) {
+			return false;
+		}
+		RequiredFactorError that = (RequiredFactorError) o;
+		return Objects.equals(this.requiredFactor, that.requiredFactor) && this.reason == that.reason;
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.requiredFactor, this.reason);
+	}
+
+	@Override
+	public String toString() {
+		return "RequiredFactorError{" + "requiredFactor=" + this.requiredFactor + ", reason=" + this.reason + '}';
+	}
+
+	public static RequiredFactorError createMissing(RequiredFactor requiredFactor) {
+		return new RequiredFactorError(requiredFactor, Reason.MISSING);
+	}
+
+	public static RequiredFactorError createExpired(RequiredFactor requiredFactor) {
+		return new RequiredFactorError(requiredFactor, Reason.EXPIRED);
+	}
+
+	/**
+	 * The reason that the error occurred.
+	 *
+	 * @author Rob Winch
+	 * @since 7.0
+	 */
+	private enum Reason {
+
+		/**
+		 * The authority was missing.
+		 * @see #isMissing()
+		 */
+		MISSING,
+		/**
+		 * The authority was considered expired.
+		 * @see #isExpired()
+		 */
+		EXPIRED
+
+	}
+
+}

+ 249 - 0
core/src/test/java/org/springframework/security/authorization/AllFactorsAuthorizationManagerTests.java

@@ -0,0 +1,249 @@
+/*
+ * 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.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthorities;
+import org.springframework.security.core.authority.FactorGrantedAuthority;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Test {@link AllFactorsAuthorizationManager}.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ */
+class AllFactorsAuthorizationManagerTests {
+
+	private static final Object DOES_NOT_MATTER = new Object();
+
+	private static RequiredFactor REQUIRED_PASSWORD = RequiredFactor
+		.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+		.build();
+
+	private static RequiredFactor EXPIRING_PASSWORD = RequiredFactor
+		.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+		.validDuration(Duration.ofHours(1))
+		.build();
+
+	@Test
+	void authorizeWhenGranted() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(REQUIRED_PASSWORD)
+			.build();
+		FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(REQUIRED_PASSWORD.getAuthority())
+			.issuedAt(Instant.now())
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isTrue();
+	}
+
+	@Test
+	void authorizeWhenConsumerGranted() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor((required) -> required.authority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY))
+			.build();
+		FactorGrantedAuthority passwordFactor = FactorGrantedAuthority
+			.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.issuedAt(Instant.now())
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isTrue();
+	}
+
+	@Test
+	void authorizeWhenUnauthenticated() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(REQUIRED_PASSWORD)
+			.build();
+		FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(REQUIRED_PASSWORD.getAuthority())
+			.issuedAt(Instant.now())
+			.build();
+		TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
+		authentication.setAuthenticated(false);
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD));
+	}
+
+	@Test
+	void authorizeWhenNullAuthentication() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(EXPIRING_PASSWORD)
+			.build();
+		Authentication authentication = null;
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(EXPIRING_PASSWORD));
+	}
+
+	@Test
+	void authorizeWhenRequiredFactorHasNullDurationThenNullIssuedAtGranted() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(REQUIRED_PASSWORD)
+			.build();
+		FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(REQUIRED_PASSWORD.getAuthority())
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isTrue();
+	}
+
+	@Test
+	void authorizeWhenRequiredFactorHasDurationAndNotFactorGrantedAuthorityThenMissing() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(EXPIRING_PASSWORD)
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password",
+				EXPIRING_PASSWORD.getAuthority());
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(EXPIRING_PASSWORD));
+	}
+
+	@Test
+	void authorizeWhenFactorAuthorityMissingThenMissing() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(REQUIRED_PASSWORD)
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER");
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD));
+	}
+
+	@Test
+	void authorizeWhenFactorGrantedAuthorityMissingThenMissing() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(REQUIRED_PASSWORD)
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password",
+				REQUIRED_PASSWORD.getAuthority());
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD));
+	}
+
+	@Test
+	void authorizeWhenExpired() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(EXPIRING_PASSWORD)
+			.build();
+		FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(EXPIRING_PASSWORD.getAuthority())
+			.issuedAt(Instant.now().minus(Duration.ofHours(2)))
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createExpired(EXPIRING_PASSWORD));
+	}
+
+	@Test
+	void authorizeWhenJustExpired() {
+		Instant now = Instant.now();
+		Duration expiresIn = Duration.ofHours(1);
+		Instant justExpired = now.minus(expiresIn);
+		Clock clock = Clock.fixed(now, ZoneId.systemDefault());
+		RequiredFactor expiringPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.validDuration(expiresIn)
+			.build();
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(expiringPassword)
+			.build();
+		allFactors.setClock(clock);
+		FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(expiringPassword.getAuthority())
+			.issuedAt(justExpired)
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createExpired(expiringPassword));
+	}
+
+	@Test
+	void authorizeWhenAlmostExpired() {
+		Instant now = Instant.now();
+		Duration expiresIn = Duration.ofHours(1);
+		Instant justExpired = now.minus(expiresIn).plus(Duration.ofNanos(1));
+		Clock clock = Clock.fixed(now, ZoneId.systemDefault());
+		RequiredFactor expiringPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.validDuration(expiresIn)
+			.build();
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(expiringPassword)
+			.build();
+		allFactors.setClock(clock);
+		FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(expiringPassword.getAuthority())
+			.issuedAt(justExpired)
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isTrue();
+	}
+
+	@Test
+	void authorizeWhenDifferentFactorGrantedAuthorityThenMissing() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(REQUIRED_PASSWORD)
+			.build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password",
+				FactorGrantedAuthority.fromAuthority(REQUIRED_PASSWORD.getAuthority()) + "DIFFERENT");
+		FactorAuthorizationDecision result = allFactors.authorize(() -> authentication, DOES_NOT_MATTER);
+		assertThat(result.isGranted()).isFalse();
+		assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD));
+	}
+
+	@Test
+	void setClockWhenNullThenIllegalArgumentException() {
+		AllFactorsAuthorizationManager<Object> allFactors = AllFactorsAuthorizationManager.builder()
+			.requiredFactor(REQUIRED_PASSWORD)
+			.build();
+		assertThatIllegalArgumentException().isThrownBy(() -> allFactors.setClock(null));
+	}
+
+	@Test
+	void builderBuildWhenEmpty() {
+		assertThatIllegalArgumentException().isThrownBy(() -> AllFactorsAuthorizationManager.builder().build());
+	}
+
+	@Test
+	void builderWhenNullRequiredFactor() {
+		AllFactorsAuthorizationManager.Builder builder = AllFactorsAuthorizationManager.builder();
+		assertThatIllegalArgumentException().isThrownBy(() -> builder.requiredFactor((RequiredFactor) null));
+	}
+
+	@Test
+	void builderWhenNullConsumerRequiredFactorBuilder() {
+		AllFactorsAuthorizationManager.Builder builder = AllFactorsAuthorizationManager.builder();
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> builder.requiredFactor((Consumer<RequiredFactor.Builder>) null));
+	}
+
+}

+ 77 - 0
core/src/test/java/org/springframework/security/authorization/FactorAuthorizationDecisionTests.java

@@ -0,0 +1,77 @@
+/*
+ * 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.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.GrantedAuthorities;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link FactorAuthorizationDecision}.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ */
+class FactorAuthorizationDecisionTests {
+
+	@Test
+	void isGrantedWhenEmptyThenTrue() {
+		FactorAuthorizationDecision decision = new FactorAuthorizationDecision(List.of());
+		assertThat(decision.isGranted()).isTrue();
+	}
+
+	@Test
+	void isGrantedWhenNotEmptyThenFalse() {
+		RequiredFactor requiredPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.build();
+		RequiredFactorError missingPassword = RequiredFactorError.createMissing(requiredPassword);
+		FactorAuthorizationDecision decision = new FactorAuthorizationDecision(List.of(missingPassword));
+		assertThat(decision.isGranted()).isFalse();
+	}
+
+	@Test
+	void getFactorErrors() {
+		RequiredFactor requiredPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.build();
+		RequiredFactorError missingPassword = RequiredFactorError.createMissing(requiredPassword);
+		List<RequiredFactorError> factorErrors = List.of(missingPassword);
+		FactorAuthorizationDecision decision = new FactorAuthorizationDecision(factorErrors);
+		assertThat(decision.getFactorErrors()).isEqualTo(factorErrors);
+	}
+
+	@Test
+	void constructorWhenNullThenThrowIllegalArgumentException() {
+		List<RequiredFactorError> factorErrors = null;
+		assertThatIllegalArgumentException().isThrownBy(() -> new FactorAuthorizationDecision(factorErrors));
+	}
+
+	@Test
+	void constructorWhenContainsNullThenThrowIllegalArgumentException() {
+		RequiredFactor requiredPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.build();
+		RequiredFactorError missingPassword = RequiredFactorError.createMissing(requiredPassword);
+		List<RequiredFactorError> hasNullValue = Arrays.asList(missingPassword, null);
+		assertThatIllegalArgumentException().isThrownBy(() -> new FactorAuthorizationDecision(hasNullValue));
+	}
+
+}

+ 64 - 0
core/src/test/java/org/springframework/security/authorization/RequiredFactorErrorTests.java

@@ -0,0 +1,64 @@
+/*
+ * 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.time.Duration;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.GrantedAuthorities;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link RequiredFactorError}.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ */
+class RequiredFactorErrorTests {
+
+	public static final RequiredFactor REQUIRED_FACTOR = RequiredFactor
+		.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+		.validDuration(Duration.ofHours(1))
+		.build();
+
+	@Test
+	void createMissing() {
+		RequiredFactorError error = RequiredFactorError.createMissing(REQUIRED_FACTOR);
+		assertThat(error.isMissing()).isTrue();
+		assertThat(error.isExpired()).isFalse();
+		assertThat(error.getRequiredFactor()).isEqualTo(REQUIRED_FACTOR);
+	}
+
+	@Test
+	void createExpired() {
+		RequiredFactorError error = RequiredFactorError.createExpired(REQUIRED_FACTOR);
+		assertThat(error.isMissing()).isFalse();
+		assertThat(error.isExpired()).isTrue();
+		assertThat(error.getRequiredFactor()).isEqualTo(REQUIRED_FACTOR);
+	}
+
+	@Test
+	void createExpiredWhenNullValidDurationThenIllegalArgumentException() {
+		RequiredFactor requiredPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.build();
+		assertThatIllegalArgumentException().isThrownBy(() -> RequiredFactorError.createExpired(requiredPassword));
+	}
+
+}

+ 60 - 0
core/src/test/java/org/springframework/security/authorization/RequiredFactorTests.java

@@ -0,0 +1,60 @@
+/*
+ * 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.time.Duration;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.GrantedAuthorities;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link RequiredFactor}.
+ *
+ * @author Rob Winch
+ * @since 7.0
+ */
+class RequiredFactorTests {
+
+	@Test
+	void builderWhenNullAuthorityIllegalArgumentException() {
+		RequiredFactor.Builder builder = RequiredFactor.builder();
+		assertThatIllegalArgumentException().isThrownBy(() -> builder.build());
+	}
+
+	@Test
+	void withAuthorityThenEquals() {
+		RequiredFactor requiredPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.build();
+		assertThat(requiredPassword.getAuthority()).isEqualTo(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY);
+		assertThat(requiredPassword.getValidDuration()).isNull();
+	}
+
+	@Test
+	void builderValidDurationThenEquals() {
+		Duration validDuration = Duration.ofMinutes(1);
+		RequiredFactor requiredPassword = RequiredFactor.withAuthority(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY)
+			.validDuration(validDuration)
+			.build();
+		assertThat(requiredPassword.getAuthority()).isEqualTo(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY);
+		assertThat(requiredPassword.getValidDuration()).isEqualTo(validDuration);
+	}
+
+}