diff --git a/core/src/main/java/org/springframework/security/authorization/MapRequiredAuthoritiesRepository.java b/core/src/main/java/org/springframework/security/authorization/MapRequiredAuthoritiesRepository.java new file mode 100644 index 0000000000..ee90bb8941 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/MapRequiredAuthoritiesRepository.java @@ -0,0 +1,55 @@ +/* + * 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.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.util.Assert; + +/** + * A {@link Map} based implementation of {@link RequiredAuthoritiesRepository}. + * + * @author Rob Winch + * @since 7.0 + */ +public final class MapRequiredAuthoritiesRepository implements RequiredAuthoritiesRepository { + + private final Map> usernameToAuthorities = new ConcurrentHashMap<>(); + + @Override + public List findRequiredAuthorities(String username) { + Assert.hasText(username, "username cannot be empty"); + return this.usernameToAuthorities.getOrDefault(username, Collections.emptyList()); + } + + public void saveRequiredAuthorities(String username, List authorities) { + Assert.hasText(username, "username cannot be empty"); + Assert.notNull(authorities, "authorities cannot be null"); + List userAuthorities = new ArrayList<>(authorities); + this.usernameToAuthorities.put(username, userAuthorities); + } + + public void deleteRequiredAuthorities(String username) { + Assert.hasText(username, "username cannot be empty"); + this.usernameToAuthorities.remove(username); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/RequiredAuthoritiesAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/RequiredAuthoritiesAuthorizationManager.java new file mode 100644 index 0000000000..e51439b9c9 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/RequiredAuthoritiesAuthorizationManager.java @@ -0,0 +1,70 @@ +/* + * 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.List; +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 requires all the authorities returned by a + * {@link RequiredAuthoritiesRepository} implementation. + * + * @param the type + * @author Rob Winch + * @since 7.0 + * @see AllAuthoritiesAuthorizationManager + */ +public class RequiredAuthoritiesAuthorizationManager implements AuthorizationManager { + + private final RequiredAuthoritiesRepository authorities; + + /** + * Creates a new instance. + * @param authorities the {@link RequiredAuthoritiesRepository} to use. Cannot be + * null. + */ + public RequiredAuthoritiesAuthorizationManager(RequiredAuthoritiesRepository authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authorities = authorities; + } + + @Override + public @Nullable AuthorizationResult authorize(Supplier authentication, + T object) { + List authorities = findAuthorities(authentication.get()); + if (authorities.isEmpty()) { + return new AuthorizationDecision(true); + } + AllAuthoritiesAuthorizationManager delegate = AllAuthoritiesAuthorizationManager + .hasAllAuthorities(authorities); + return delegate.authorize(authentication, object); + } + + private List findAuthorities(@Nullable Authentication authentication) { + if (authentication == null) { + return List.of(); + } + String username = authentication.getName(); + return this.authorities.findRequiredAuthorities(username); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/RequiredAuthoritiesRepository.java b/core/src/main/java/org/springframework/security/authorization/RequiredAuthoritiesRepository.java new file mode 100644 index 0000000000..186763cc0a --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/RequiredAuthoritiesRepository.java @@ -0,0 +1,40 @@ +/* + * 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.List; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +/** + * Finds additional required authorities for the provided {@link Authentication#getName()} + * + * @author Rob Winch + * @since 7.0 + */ +public interface RequiredAuthoritiesRepository { + + /** + * Finds additional required {@link GrantedAuthority#getAuthority()}s for the provided + * username. + * @param username the username. Cannot be null or empty. + * @return the additional authorities required. + */ + List findRequiredAuthorities(String username); + +} diff --git a/core/src/test/java/org/springframework/security/authorization/MapRequiredAuthoritiesRepositoryTests.java b/core/src/test/java/org/springframework/security/authorization/MapRequiredAuthoritiesRepositoryTests.java new file mode 100644 index 0000000000..90a8cf5f23 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/MapRequiredAuthoritiesRepositoryTests.java @@ -0,0 +1,95 @@ +/* + * 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.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; + +/** + * Test {@link MapRequiredAuthoritiesRepository}. + * + * @author Rob Winch + * @since 7.0 + */ +class MapRequiredAuthoritiesRepositoryTests { + + private MapRequiredAuthoritiesRepository repository = new MapRequiredAuthoritiesRepository(); + + private String username = "user"; + + private List authorities = List.of(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, + GrantedAuthorities.FACTOR_OTT_AUTHORITY); + + @Test + void workflow() { + this.repository.saveRequiredAuthorities(this.username, this.authorities); + assertThat(this.repository.findRequiredAuthorities(this.username)) + .containsExactlyInAnyOrderElementsOf(this.authorities); + List otherAuthorities = List.of(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, + GrantedAuthorities.FACTOR_WEBAUTHN_AUTHORITY); + this.repository.saveRequiredAuthorities(this.username, otherAuthorities); + assertThat(this.repository.findRequiredAuthorities(this.username)) + .containsExactlyInAnyOrderElementsOf(otherAuthorities); + this.repository.deleteRequiredAuthorities(this.username); + assertThat(this.repository.findRequiredAuthorities(this.username)).isEmpty(); + } + + @Test + void findRequiredAuthoritiesWhenNullUsernameThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.findRequiredAuthorities(null)); + } + + @Test + void findRequiredAuthoritiesWhenEmptyUsernameThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.findRequiredAuthorities("")); + } + + @Test + void saveRequiredAuthoritiesWhenNullUsernameThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.saveRequiredAuthorities(null, this.authorities)); + } + + @Test + void saveRequiredAuthoritiesWhenEmptyUsernameThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.saveRequiredAuthorities("", this.authorities)); + } + + @Test + void saveRequiredAuthoritiesWhenNullAuthoritiesThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.saveRequiredAuthorities(this.username, null)); + } + + @Test + void deleteRequiredAuthoritiesWhenNullUsernameThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.deleteRequiredAuthorities(null)); + } + + @Test + void deleteRequiredAuthoritiesWhenEmptyUsernameThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.deleteRequiredAuthorities("")); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/RequiredAuthoritiesAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/RequiredAuthoritiesAuthorizationManagerTests.java new file mode 100644 index 0000000000..ca8be887f4 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/RequiredAuthoritiesAuthorizationManagerTests.java @@ -0,0 +1,106 @@ +/* + * 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 java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +/** + * Tests for {@link RequiredAuthoritiesAuthorizationManager}. + * + * @author Rob Winch + * @since 7.0 + */ +@ExtendWith(MockitoExtension.class) +class RequiredAuthoritiesAuthorizationManagerTests { + + @Mock + private RequiredAuthoritiesRepository repository; + + private static final Object DOES_NOT_MATTER = ""; + + private RequiredAuthoritiesAuthorizationManager manager; + + private Supplier authentication = () -> new TestingAuthenticationToken("user", "password", + "ROLE_USER", "ROLE_ADMIN"); + + @BeforeEach + void setup() { + this.manager = new RequiredAuthoritiesAuthorizationManager<>(this.repository); + } + + @Test + void constructorWhenNullRepositoryThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> new RequiredAuthoritiesAuthorizationManager(null)); + } + + @Test + void authorizeWhenNoResults() { + returnAuthorities(Collections.emptyList()); + assertGranted(); + } + + @Test + void authorizeWhenAdditionalAuthoriteisAndGranted() { + returnAuthorities( + this.authentication.get().getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()); + assertGranted(); + } + + @Test + void authorizeWhenAdditionalAuthoriteisAndDenied() { + returnAuthorities(List.of("NOT_FOUND")); + assertDenied(); + } + + @Test + void authorizeWhenOneFoundAndDenied() { + returnAuthorities(List.of("ROLE_USER", "NOT_FOUND")); + assertDenied(); + } + + private void returnAuthorities(List authorities) { + given(this.repository.findRequiredAuthorities(any())).willReturn(authorities); + } + + private void assertGranted() { + AuthorizationResult authz = this.manager.authorize(this.authentication, DOES_NOT_MATTER); + assertThat(authz.isGranted()).isTrue(); + } + + private void assertDenied() { + AuthorizationResult authz = this.manager.authorize(this.authentication, DOES_NOT_MATTER); + assertThat(authz.isGranted()).isFalse(); + } + +}