diff --git a/core/src/main/java/org/springframework/security/authorization/AllFactorsAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/AllFactorsAuthorizationManager.java new file mode 100644 index 0000000000..a31117c656 --- /dev/null +++ b/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 implements AuthorizationManager { + + private Clock clock = Clock.systemUTC(); + + private final List requiredFactors; + + /** + * Creates a new instance. + * @param requiredFactors the authorities that are required. + */ + private AllFactorsAuthorizationManager(List 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 authentication, + T object) { + List currentFactorAuthorities = getFactorGrantedAuthorities(authentication.get()); + List 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 currentFactors) { + Optional 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 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 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) { + 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 the type. + * @return the {@link AllFactorsAuthorizationManager} + */ + public AllFactorsAuthorizationManager build() { + return new AllFactorsAuthorizationManager(this.requiredFactors); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/FactorAuthorizationDecision.java b/core/src/main/java/org/springframework/security/authorization/FactorAuthorizationDecision.java new file mode 100644 index 0000000000..6a3c428834 --- /dev/null +++ b/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 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 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 getFactorErrors() { + return this.factorErrors; + } + + /** + * Returns {@code getFactorErrors().isEmpty()}. + * @return + */ + @Override + public boolean isGranted() { + return this.factorErrors.isEmpty(); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/RequiredFactor.java b/core/src/main/java/org/springframework/security/authorization/RequiredFactor.java new file mode 100644 index 0000000000..b5a1389596 --- /dev/null +++ b/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); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/RequiredFactorError.java b/core/src/main/java/org/springframework/security/authorization/RequiredFactorError.java new file mode 100644 index 0000000000..0d6cada187 --- /dev/null +++ b/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 + + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/AllFactorsAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/AllFactorsAuthorizationManagerTests.java new file mode 100644 index 0000000000..7e3328088c --- /dev/null +++ b/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 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 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 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 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 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 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 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 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 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 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 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 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 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) null)); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/FactorAuthorizationDecisionTests.java b/core/src/test/java/org/springframework/security/authorization/FactorAuthorizationDecisionTests.java new file mode 100644 index 0000000000..ebada684b8 --- /dev/null +++ b/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 factorErrors = List.of(missingPassword); + FactorAuthorizationDecision decision = new FactorAuthorizationDecision(factorErrors); + assertThat(decision.getFactorErrors()).isEqualTo(factorErrors); + } + + @Test + void constructorWhenNullThenThrowIllegalArgumentException() { + List 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 hasNullValue = Arrays.asList(missingPassword, null); + assertThatIllegalArgumentException().isThrownBy(() -> new FactorAuthorizationDecision(hasNullValue)); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/RequiredFactorErrorTests.java b/core/src/test/java/org/springframework/security/authorization/RequiredFactorErrorTests.java new file mode 100644 index 0000000000..eafa6029be --- /dev/null +++ b/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)); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/RequiredFactorTests.java b/core/src/test/java/org/springframework/security/authorization/RequiredFactorTests.java new file mode 100644 index 0000000000..98e28f256d --- /dev/null +++ b/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); + } + +}