diff --git a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc index af3667531d4..e6fd6e5b403 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc @@ -949,6 +949,21 @@ fun getMessages(): List { } ---- ====== +[[method-security-has-scope]] +=== Using `hasScope` in Method Security + +Because method security expressions can evaluation `AuthorizationManager` instances, you can also use the `hasScope` API by publishing a `DefaultOAuth2AuthorizationManagerFactory` `@Bean`: + +include-code::./MethodSecurityHasScopeConfiguration[tag=declare-factory,indent=0] + +and then doing: + +include-code::./MessageService[tag=protected-method,indent=0] + +If you are using xref:servlet/authentication/mfa.adoc[Spring Security's MFA feature], then you can supply its `AuthorizationManagerFactory` instance to ensure that your authentication factors are automatically checked as well by including it in your `DefaultOAuth2AuthorizationManagerFactory` constructor as follows: + +include-code::./MethodSecurityHasScopeMfaConfiguration[tag=declare-factory,indent=0] + [[oauth2resourceserver-jwt-authorization-extraction]] === Extracting Authorities Manually diff --git a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc index 80654024cbd..6c78c32480c 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc @@ -638,6 +638,21 @@ fun getMessages(): List {} ---- ====== +[[method-security-has-scope]] +=== Using `hasScope` in Method Security + +Because method security expressions can evaluation `AuthorizationManager` instances, you can also use the `hasScope` API by publishing a `DefaultOAuth2AuthorizationManagerFactory` `@Bean`: + +include-code::./MethodSecurityHasScopeConfiguration[tag=declare-factory,indent=0] + +and then doing: + +include-code::./MessageService[tag=protected-method,indent=0] + +If you are using xref:servlet/authentication/mfa.adoc[Spring Security's MFA feature], then you can supply its `AuthorizationManagerFactory` instance to ensure that your authentication factors are automatically checked as well by including it in your `DefaultOAuth2AuthorizationManagerFactory` constructor as follows: + +include-code::./MethodSecurityHasScopeMfaConfiguration[tag=declare-factory,indent=0] + [[oauth2resourceserver-opaque-authorization-extraction]] === Extracting Authorities Manually diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.java new file mode 100644 index 00000000000..24e42202771 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.java @@ -0,0 +1,16 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +@Service +class MessageService { + + // tag::protected-method[] + @PreAuthorize("@oauth2.hasScope('message:read')") + String readMessage() { + return "message"; + } + // end::protected-method[] +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.java new file mode 100644 index 00000000000..d88fe6cea5c --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.java @@ -0,0 +1,18 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory; +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory; + +@Configuration +@EnableMethodSecurity +class MethodSecurityHasScopeConfiguration { + // tag::declare-factory[] + @Bean + OAuth2AuthorizationManagerFactory oauth2() { + return new DefaultOAuth2AuthorizationManagerFactory<>(); + } + // end::declare-factory[] +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.java new file mode 100644 index 00000000000..e77b63ece61 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.java @@ -0,0 +1,59 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@ExtendWith(SpringTestContextExtension.class) +@ExtendWith(SpringExtension.class) +@SecurityTestExecutionListeners +public class MethodSecurityHasScopeConfigurationTests { + public final SpringTestContext spring = new SpringTestContext(this).mockMvcAfterSpringSecurityOk(); + + @Autowired + private MessageService messages; + + @Test + @WithMockUser(authorities = "SCOPE_message:read") + void readMessageWhenMessageReadThenAllowed() { + this.spring.register(MethodSecurityHasScopeConfiguration.class, MessageService.class).autowire(); + this.messages.readMessage(); + } + + @Test + @WithMockUser + void readMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeConfiguration.class, MessageService.class).autowire(); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(this.messages::readMessage); + } + + @Test + @WithMockUser(authorities = { "SCOPE_message:read", "FACTOR_BEARER", "FACTOR_X509" }) + void mfaReadMessageWhenMessageReadAndFactorsThenAllowed() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration.class, MessageService.class).autowire(); + this.messages.readMessage(); + } + + @Test + @WithMockUser(authorities = { "SCOPE_message:read" }) + void mfaReadMessageWhenMessageReadThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration.class, MessageService.class).autowire(); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(this.messages::readMessage); + } + + @Test + @WithMockUser + void mfaReadMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration.class, MessageService.class).autowire(); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(this.messages::readMessage); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.java new file mode 100644 index 00000000000..93a4eeffc80 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.java @@ -0,0 +1,21 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationManagerFactory; +import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory; +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory; + +@Configuration +@EnableMethodSecurity +@EnableMultiFactorAuthentication(authorities = { "FACTOR_BEARER", "FACTOR_X509" }) +class MethodSecurityHasScopeMfaConfiguration { + // tag::declare-factory[] + @Bean + OAuth2AuthorizationManagerFactory oauth2(AuthorizationManagerFactory authz) { + return new DefaultOAuth2AuthorizationManagerFactory<>(authz); + } + // end::declare-factory[] +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.kt new file mode 100644 index 00000000000..e16f076a346 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.kt @@ -0,0 +1,15 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.stereotype.Service + + +@Service +open class MessageService { + // tag::protected-method[] + @PreAuthorize("@oauth2.hasScope('message:read')") + open fun readMessage(): String { + return "message" + } + // end::protected-method[] +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.kt new file mode 100644 index 00000000000..0fa8ac5e7b8 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.kt @@ -0,0 +1,18 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory + +@Configuration +@EnableMethodSecurity +open class MethodSecurityHasScopeConfiguration { + // tag::declare-factory[] + @Bean + open fun oauth2(): OAuth2AuthorizationManagerFactory { + return DefaultOAuth2AuthorizationManagerFactory() + } + // end::declare-factory[] +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.kt new file mode 100644 index 00000000000..155f03ffcd1 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.kt @@ -0,0 +1,61 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.test.context.junit.jupiter.SpringExtension + +@ExtendWith(SpringTestContextExtension::class) +@ExtendWith(SpringExtension::class) +@SecurityTestExecutionListeners +class MethodSecurityHasScopeConfigurationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this).mockMvcAfterSpringSecurityOk() + + @Autowired + var messages: MessageService? = null + + @Test + @WithMockUser(authorities = ["SCOPE_message:read"]) + fun readMessageWhenMessageReadThenAllowed() { + this.spring.register(MethodSecurityHasScopeConfiguration::class.java, MessageService::class.java).autowire() + this.messages!!.readMessage() + } + + @Test + @WithMockUser + fun readMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeConfiguration::class.java, MessageService::class.java).autowire() + Assertions.assertThatExceptionOfType(AccessDeniedException::class.java) + .isThrownBy({ this.messages!!.readMessage() }) + } + + @Test + @WithMockUser(authorities = ["SCOPE_message:read", "FACTOR_BEARER", "FACTOR_X509"]) + fun mfaReadMessageWhenMessageReadAndFactorsThenAllowed() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration::class.java, MessageService::class.java).autowire() + this.messages!!.readMessage() + } + + @Test + @WithMockUser(authorities = ["SCOPE_message:read"]) + fun mfaReadMessageWhenMessageReadThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration::class.java, MessageService::class.java).autowire() + Assertions.assertThatExceptionOfType(AccessDeniedException::class.java) + .isThrownBy({ this.messages!!.readMessage() }) + } + + @Test + @WithMockUser + fun mfaReadMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration::class.java, MessageService::class.java).autowire() + Assertions.assertThatExceptionOfType(AccessDeniedException::class.java) + .isThrownBy({ this.messages!!.readMessage() }) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.kt new file mode 100644 index 00000000000..3672f76e224 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.kt @@ -0,0 +1,20 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authorization.AuthorizationManagerFactory +import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory + +@Configuration +@EnableMethodSecurity +@EnableMultiFactorAuthentication(authorities = ["FACTOR_BEARER", "FACTOR_X509"]) +open class MethodSecurityHasScopeMfaConfiguration { + // tag::declare-factory[] + @Bean + open fun oauth2(authz: AuthorizationManagerFactory): OAuth2AuthorizationManagerFactory { + return DefaultOAuth2AuthorizationManagerFactory(authz) + } // end::declare-factory[] +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/DefaultOAuth2AuthorizationManagerFactory.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/DefaultOAuth2AuthorizationManagerFactory.java new file mode 100644 index 00000000000..deb175e221e --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/DefaultOAuth2AuthorizationManagerFactory.java @@ -0,0 +1,89 @@ +/* + * Copyright 2025-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.oauth2.core.authorization; + +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagerFactory; +import org.springframework.security.authorization.DefaultAuthorizationManagerFactory; +import org.springframework.util.Assert; + +/** + * A factory for creating different kinds of {@link AuthorizationManager} instances. + * + * @param the type of object that the authorization check is being done on + * @author Ngoc Nhan + * @since 7.1 + */ +public final class DefaultOAuth2AuthorizationManagerFactory implements OAuth2AuthorizationManagerFactory { + + private String scopePrefix = "SCOPE_"; + + private final AuthorizationManagerFactory authorizationManagerFactory; + + public DefaultOAuth2AuthorizationManagerFactory() { + this(new DefaultAuthorizationManagerFactory<>()); + } + + public DefaultOAuth2AuthorizationManagerFactory(AuthorizationManagerFactory authorizationManagerFactory) { + Assert.notNull(authorizationManagerFactory, "authorizationManagerFactory can not be null"); + this.authorizationManagerFactory = authorizationManagerFactory; + } + + /** + * Sets the prefix used to create an authority name from a scope name. Can be an empty + * string. + * @param scopePrefix the scope prefix to use + */ + public void setScopePrefix(String scopePrefix) { + Assert.notNull(scopePrefix, "scopePrefix can not be null"); + this.scopePrefix = scopePrefix; + } + + @Override + public AuthorizationManager hasScope(String scope) { + Assert.notNull(scope, "scope can not be null"); + assertScope(scope); + return this.authorizationManagerFactory.hasAuthority(this.scopePrefix + scope); + } + + @Override + public AuthorizationManager hasAnyScope(String... scopes) { + return this.authorizationManagerFactory.hasAnyAuthority(this.mappedScopes(scopes)); + } + + @Override + public AuthorizationManager hasAllScopes(String... scopes) { + return this.authorizationManagerFactory.hasAllAuthorities(this.mappedScopes(scopes)); + } + + private String[] mappedScopes(String... scopes) { + Assert.notNull(scopes, "scopes can not be null"); + String[] mappedScopes = new String[scopes.length]; + for (int i = 0; i < scopes.length; i++) { + assertScope(scopes[i]); + mappedScopes[i] = this.scopePrefix + scopes[i]; + } + return mappedScopes; + } + + private void assertScope(String scope) { + Assert.isTrue(!scope.startsWith(this.scopePrefix), () -> scope + " should not start with '" + this.scopePrefix + + "' since '" + this.scopePrefix + + "' is automatically prepended when using hasScope and hasAnyScope. Consider using AuthorizationManagerFactory#hasAuthority or #hasAnyAuthority instead."); + } + +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagerFactory.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagerFactory.java new file mode 100644 index 00000000000..9dfd5419ae8 --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagerFactory.java @@ -0,0 +1,95 @@ +/* + * Copyright 2025-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.oauth2.core.authorization; + +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; + +/** + * A factory for creating different kinds of {@link AuthorizationManager} instances. + * + * @param the type of object that the authorization check is being done on + * @author Ngoc Nhan + * @since 7.1 + */ +public interface OAuth2AuthorizationManagerFactory { + + /** + * Create an {@link AuthorizationManager} that requires an {@link Authentication} to + * have a {@code SCOPE_scope} authority. + * + *

+ * For example, if you call {@code hasScope("read")}, then this will require that each + * authentication have a {@link org.springframework.security.core.GrantedAuthority} + * whose value is {@code SCOPE_read}. + * + *

+ * This would equivalent to calling + * {@code AuthorityAuthorizationManager#hasAuthority("SCOPE_read")}. + * @param scope the scope value to require + * @return an {@link AuthorizationManager} that requires a {@code "SCOPE_scope"} + * authority + */ + default AuthorizationManager hasScope(String scope) { + return OAuth2AuthorizationManagers.hasScope(scope); + } + + /** + * Create an {@link AuthorizationManager} that requires an {@link Authentication} to + * have at least one authority among {@code SCOPE_scope1}, {@code SCOPE_scope2}, ... + * {@code SCOPE_scopeN}. + * + *

+ * For example, if you call {@code hasAnyScope("read", "write")}, then this will + * require that each authentication have at least a + * {@link org.springframework.security.core.GrantedAuthority} whose value is either + * {@code SCOPE_read} or {@code SCOPE_write}. + * + *

+ * This would equivalent to calling + * {@code AuthorityAuthorizationManager#hasAnyAuthority("SCOPE_read", "SCOPE_write")}. + * @param scopes the scope values to allow + * @return an {@link AuthorizationManager} that requires at least one authority among + * {@code "SCOPE_scope1"}, {@code SCOPE_scope2}, ... {@code SCOPE_scopeN}. + */ + default AuthorizationManager hasAnyScope(String... scopes) { + return OAuth2AuthorizationManagers.hasAnyScope(scopes); + } + + /** + * Create an {@link AuthorizationManager} that requires an {@link Authentication} to + * have all authorities {@code SCOPE_scope1}, {@code SCOPE_scope2}, ... + * {@code SCOPE_scopeN}. + * + *

+ * For example, if you call {@code hasAllScopes("read", "write")}, then each + * {@link org.springframework.security.core.Authentication} must have all + * {@link org.springframework.security.core.GrantedAuthority} values of + * {@code SCOPE_read} and {@code SCOPE_write}. + * + *

+ * This would be equivalent to calling + * {@code AllAuthoritiesAuthorizationManager#hasAllAuthorities("SCOPE_read", "SCOPE_write")}. + * @param scopes the scope values to require + * @return an {@link AuthorizationManager} that requires all authorities + * {@code SCOPE_scope1}, {@code SCOPE_scope2}, ... {@code SCOPE_scopeN}. + */ + default AuthorizationManager hasAllScopes(String... scopes) { + return OAuth2AuthorizationManagers.hasAllScopes(scopes); + } + +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagers.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagers.java index 50e7bfb6457..36d3da05ae9 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagers.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagers.java @@ -16,6 +16,7 @@ package org.springframework.security.oauth2.core.authorization; +import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager; import org.springframework.security.authorization.AuthorityAuthorizationManager; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.core.Authentication; @@ -28,6 +29,7 @@ * @author Josh Cummings * @since 6.2 * @see AuthorityAuthorizationManager + * @see AllAuthoritiesAuthorizationManager */ public final class OAuth2AuthorizationManagers { @@ -85,6 +87,34 @@ public static AuthorizationManager hasAnyScope(String... scopes) { return AuthorityAuthorizationManager.hasAnyAuthority(mappedScopes); } + /** + * Create an {@link AuthorizationManager} that requires an {@link Authentication} to + * have all authorities {@code SCOPE_scope1}, {@code SCOPE_scope2}, ... + * {@code SCOPE_scopeN}. + * + *

+ * For example, if you call {@code hasAllScopes("read", "write")}, then each + * {@link org.springframework.security.core.Authentication} must have all + * {@link org.springframework.security.core.GrantedAuthority} values of + * {@code SCOPE_read} and {@code SCOPE_write}. + * + *

+ * This would be equivalent to calling + * {@code AllAuthoritiesAuthorizationManager#hasAllAuthorities("SCOPE_read", "SCOPE_write")}. + * @param scopes the scope values to require + * @return an {@link AuthorizationManager} that requires all authorities + * {@code SCOPE_scope1}, {@code SCOPE_scope2}, ... {@code SCOPE_scopeN}. + * @since 7.1 + */ + public static AuthorizationManager hasAllScopes(String... scopes) { + String[] mappedScopes = new String[scopes.length]; + for (int i = 0; i < scopes.length; i++) { + assertScope(scopes[i]); + mappedScopes[i] = "SCOPE_" + scopes[i]; + } + return AllAuthoritiesAuthorizationManager.hasAllAuthorities(mappedScopes); + } + private static void assertScope(String scope) { Assert.isTrue(!scope.startsWith("SCOPE_"), () -> scope + " should not start with SCOPE_ since SCOPE_" diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagerFactoryTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagerFactoryTests.java new file mode 100644 index 00000000000..b9f48127c13 --- /dev/null +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/authorization/OAuth2AuthorizationManagerFactoryTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2025-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.oauth2.core.authorization; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.authorization.AuthorityAuthorizationManager; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagerFactories; +import org.springframework.security.authorization.AuthorizationResult; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OAuth2AuthorizationManagerFactory}. + * + * @author Ngoc Nhan + */ +public class OAuth2AuthorizationManagerFactoryTests { + + private static final String MSG_READ = "message:read"; + + private static final String MSG_WRITE = "message:write"; + + private static final String SCOPE_MSG_READ = "SCOPE_message:read"; + + private static final String SCOPE_MSG_WRITE = "SCOPE_message:write"; + + @Test + public void hasScopeReturnsAuthorityAuthorizationManagerByDefault() { + OAuth2AuthorizationManagerFactory factory = new DefaultOAuth2AuthorizationManagerFactory<>(); + AuthorizationManager authorizationManager = factory.hasScope(MSG_READ); + assertThat(authorizationManager).isInstanceOf(AuthorityAuthorizationManager.class); + } + + @Test + public void hasAnyScopeReturnsAuthorityAuthorizationManagerByDefault() { + OAuth2AuthorizationManagerFactory factory = new DefaultOAuth2AuthorizationManagerFactory<>(); + AuthorizationManager authorizationManager = factory.hasAnyScope(MSG_READ, MSG_WRITE); + assertThat(authorizationManager).isInstanceOf(AuthorityAuthorizationManager.class); + } + + @Test + public void hasAllScopesReturnsAuthorityAuthorizationManagerByDefault() { + OAuth2AuthorizationManagerFactory factory = new DefaultOAuth2AuthorizationManagerFactory<>(); + AuthorizationManager authorizationManager = factory.hasAnyScope(MSG_READ, MSG_WRITE); + assertThat(authorizationManager).isInstanceOf(AuthorityAuthorizationManager.class); + } + + @Test + public void hasScopeWhenSetAuthorizationManagerFactories() { + DefaultOAuth2AuthorizationManagerFactory factory = new DefaultOAuth2AuthorizationManagerFactory<>( + AuthorizationManagerFactories.multiFactor().requireFactors(SCOPE_MSG_READ).build()); + assertUserGranted(factory.hasScope(MSG_READ), SCOPE_MSG_READ); + assertUserDenied(factory.hasScope(MSG_WRITE), SCOPE_MSG_READ); + } + + @Test + public void hasAnyScopeWhenSetAuthorizationManagerFactories() { + DefaultOAuth2AuthorizationManagerFactory factory = new DefaultOAuth2AuthorizationManagerFactory<>( + AuthorizationManagerFactories.multiFactor().requireFactors(SCOPE_MSG_READ).build()); + assertUserGranted(factory.hasAnyScope(MSG_READ), SCOPE_MSG_READ); + assertUserDenied(factory.hasAnyScope(MSG_WRITE), SCOPE_MSG_READ); + } + + @Test + public void hasAllScopesWhenSetAuthorizationManagerFactories() { + DefaultOAuth2AuthorizationManagerFactory factory = new DefaultOAuth2AuthorizationManagerFactory<>( + AuthorizationManagerFactories.multiFactor() + .requireFactors(SCOPE_MSG_READ, SCOPE_MSG_WRITE) + .build()); + assertUserGranted(factory.hasAllScopes(MSG_READ, MSG_WRITE), SCOPE_MSG_READ, SCOPE_MSG_WRITE); + assertUserDenied(factory.hasAllScopes(MSG_READ, MSG_WRITE), SCOPE_MSG_READ); + } + + private void assertUserGranted(AuthorizationManager manager, String... authorities) { + AuthorizationResult authorizationResult = createAuthorizationResult(manager, authorities); + assertThat(authorizationResult).isNotNull(); + assertThat(authorizationResult.isGranted()).isTrue(); + } + + private void assertUserDenied(AuthorizationManager manager, String... authorities) { + AuthorizationResult authorizationResult = createAuthorizationResult(manager, authorities); + assertThat(authorizationResult).isNotNull(); + assertThat(authorizationResult.isGranted()).isFalse(); + } + + private AuthorizationResult createAuthorizationResult(AuthorizationManager manager, String... authorities) { + TestingAuthenticationToken authenticatedUser = new TestingAuthenticationToken("user", "pass", authorities); + return manager.authorize(() -> authenticatedUser, ""); + } + +}