diff --git a/.github/workflows/merge-dependabot-pr.yml b/.github/workflows/merge-dependabot-pr.yml index 4244d10bd2e..1bda517c9a1 100644 --- a/.github/workflows/merge-dependabot-pr.yml +++ b/.github/workflows/merge-dependabot-pr.yml @@ -1,13 +1,52 @@ name: Merge Dependabot PR -on: - pull_request: +on: pull_request_target run-name: Merge Dependabot PR ${{ github.ref_name }} +permissions: write-all + jobs: merge-dependabot-pr: - permissions: write-all - uses: spring-io/spring-github-workflows/.github/workflows/spring-merge-dependabot-pr.yml@1e8b0587a1f4f01697f9753fa3339c3e0d30f396 - with: - mergeArguments: '--auto --rebase' + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + + - uses: actions/checkout@v4 + with: + show-progress: false + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Set Milestone to Dependabot Pull Request + id: set-milestone + run: | + if test -f pom.xml + then + CURRENT_VERSION=$(mvn help:evaluate -Dexpression="project.version" -q -DforceStdout) + else + CURRENT_VERSION=$(cat gradle.properties | sed -n '/^version=/ { s/^version=//;p }') + fi + export CANDIDATE_VERSION=${CURRENT_VERSION/-SNAPSHOT} + MILESTONE=$(gh api repos/$GITHUB_REPOSITORY/milestones --jq 'map(select(.due_on != null and (.title | startswith(env.CANDIDATE_VERSION)))) | .[0] | .title') + + if [ -z $MILESTONE ] + then + gh run cancel ${{ github.run_id }} + echo "::warning title=Cannot merge::No scheduled milestone for $CURRENT_VERSION version" + else + gh pr edit ${{ github.event.pull_request.number }} --milestone $MILESTONE + echo mergeEnabled=true >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Merge Dependabot pull request + if: steps.set-milestone.outputs.mergeEnabled + run: gh pr merge ${{ github.event.pull_request.number }} --auto --rebase + env: + GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} diff --git a/.github/workflows/trigger-dependabot-auto-merge-forward.yml b/.github/workflows/trigger-dependabot-auto-merge-forward.yml index 0f1bf228c4e..9bbb2ccbd5f 100644 --- a/.github/workflows/trigger-dependabot-auto-merge-forward.yml +++ b/.github/workflows/trigger-dependabot-auto-merge-forward.yml @@ -1,8 +1,9 @@ name: Trigger Dependabot Auto Merge Forward on: - pull_request: - types: [closed] + push: + branches: + - '*.x' permissions: read-all @@ -10,7 +11,7 @@ jobs: trigger-worflow: name: Trigger Workflow runs-on: ubuntu-latest - if: ${{ github.event.pull_request.merged == true && github.actor == 'dependabot[bot]' && github.repository == 'spring-projects/spring-security' }} + if: ${{ github.event.commits[0].author.username == 'dependabot[bot]' && github.repository == 'spring-projects/spring-security' }} steps: - name: Checkout id: checkout diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java new file mode 100644 index 00000000000..9b561eb221e --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 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.config.annotation.method.configuration; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.aop.framework.AopInfrastructureBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.security.authorization.AuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.method.AuthorizationAdvisor; + +@Configuration(proxyBeanMethods = false) +final class AuthorizationProxyConfiguration implements AopInfrastructureBean { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static AuthorizationAdvisorProxyFactory authorizationProxyFactory(ObjectProvider provider) { + List advisors = new ArrayList<>(); + provider.forEach(advisors::add); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + factory.setAdvisors(advisors); + return factory; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java index 39567ddfdca..45908fb549a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java @@ -20,6 +20,7 @@ import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; @@ -48,7 +49,7 @@ */ @Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) -final class Jsr250MethodSecurityConfiguration implements ImportAware { +final class Jsr250MethodSecurityConfiguration implements ImportAware, AopInfrastructureBean { private int interceptorOrderOffset; diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java index 4b561360a73..928ed485484 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java @@ -56,6 +56,7 @@ public String[] selectImports(@NonNull AnnotationMetadata importMetadata) { if (annotation.jsr250Enabled()) { imports.add(Jsr250MethodSecurityConfiguration.class.getName()); } + imports.add(AuthorizationProxyConfiguration.class.getName()); return imports.toArray(new String[0]); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java index 4c10dc4e5b6..7fea76850df 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java @@ -27,7 +27,6 @@ import org.jetbrains.annotations.Nullable; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; @@ -36,7 +35,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportAware; import org.springframework.context.annotation.Role; -import org.springframework.core.Ordered; import org.springframework.core.type.AnnotationMetadata; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; @@ -44,6 +42,7 @@ import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.authorization.AuthorizationEventPublisher; import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationAdvisor; import org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.authorization.method.PostAuthorizeAuthorizationManager; @@ -65,7 +64,7 @@ */ @Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) -final class PrePostMethodSecurityConfiguration implements ImportAware { +final class PrePostMethodSecurityConfiguration implements ImportAware, AopInfrastructureBean { private int interceptorOrderOffset; @@ -175,8 +174,8 @@ public void setImportMetadata(AnnotationMetadata importMetadata) { this.interceptorOrderOffset = annotation.offset(); } - private static final class DeferringMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { + private static final class DeferringMethodInterceptor + implements AuthorizationAdvisor { private final Pointcut pointcut; diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java index 848934bb257..2bfa745f676 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java @@ -16,9 +16,18 @@ package org.springframework.security.config.annotation.method.configuration; +import java.util.function.Consumer; +import java.util.function.Supplier; + import io.micrometer.observation.ObservationRegistry; +import org.aopalliance.aop.Advice; +import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.Pointcut; +import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanDefinition; @@ -29,6 +38,7 @@ import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationAdvisor; import org.springframework.security.authorization.method.AuthorizationManagerAfterReactiveMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeReactiveMethodInterceptor; import org.springframework.security.authorization.method.MethodInvocationResult; @@ -38,6 +48,7 @@ import org.springframework.security.authorization.method.PreFilterAuthorizationReactiveMethodInterceptor; import org.springframework.security.authorization.method.PrePostTemplateDefaults; import org.springframework.security.config.core.GrantedAuthorityDefaults; +import org.springframework.util.function.SingletonSupplier; /** * Configuration for a {@link ReactiveAuthenticationManager} based Method Security. @@ -46,54 +57,56 @@ * @since 5.8 */ @Configuration(proxyBeanMethods = false) -final class ReactiveAuthorizationManagerMethodSecurityConfiguration { +final class ReactiveAuthorizationManagerMethodSecurityConfiguration implements AopInfrastructureBean { @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static PreFilterAuthorizationReactiveMethodInterceptor preFilterInterceptor( - MethodSecurityExpressionHandler expressionHandler, + static MethodInterceptor preFilterAuthorizationMethodInterceptor(MethodSecurityExpressionHandler expressionHandler, ObjectProvider defaultsObjectProvider) { PreFilterAuthorizationReactiveMethodInterceptor interceptor = new PreFilterAuthorizationReactiveMethodInterceptor( expressionHandler); - defaultsObjectProvider.ifAvailable(interceptor::setTemplateDefaults); - return interceptor; + return new DeferringMethodInterceptor<>(interceptor, + (i) -> defaultsObjectProvider.ifAvailable(i::setTemplateDefaults)); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static AuthorizationManagerBeforeReactiveMethodInterceptor preAuthorizeInterceptor( + static MethodInterceptor preAuthorizeAuthorizationMethodInterceptor( MethodSecurityExpressionHandler expressionHandler, ObjectProvider defaultsObjectProvider, ObjectProvider registryProvider) { PreAuthorizeReactiveAuthorizationManager manager = new PreAuthorizeReactiveAuthorizationManager( expressionHandler); - defaultsObjectProvider.ifAvailable(manager::setTemplateDefaults); ReactiveAuthorizationManager authorizationManager = manager(manager, registryProvider); - return AuthorizationManagerBeforeReactiveMethodInterceptor.preAuthorize(authorizationManager); + AuthorizationAdvisor interceptor = AuthorizationManagerBeforeReactiveMethodInterceptor + .preAuthorize(authorizationManager); + return new DeferringMethodInterceptor<>(interceptor, + (i) -> defaultsObjectProvider.ifAvailable(manager::setTemplateDefaults)); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static PostFilterAuthorizationReactiveMethodInterceptor postFilterInterceptor( - MethodSecurityExpressionHandler expressionHandler, + static MethodInterceptor postFilterAuthorizationMethodInterceptor(MethodSecurityExpressionHandler expressionHandler, ObjectProvider defaultsObjectProvider) { PostFilterAuthorizationReactiveMethodInterceptor interceptor = new PostFilterAuthorizationReactiveMethodInterceptor( expressionHandler); - defaultsObjectProvider.ifAvailable(interceptor::setTemplateDefaults); - return interceptor; + return new DeferringMethodInterceptor<>(interceptor, + (i) -> defaultsObjectProvider.ifAvailable(i::setTemplateDefaults)); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static AuthorizationManagerAfterReactiveMethodInterceptor postAuthorizeInterceptor( + static MethodInterceptor postAuthorizeAuthorizationMethodInterceptor( MethodSecurityExpressionHandler expressionHandler, ObjectProvider defaultsObjectProvider, ObjectProvider registryProvider) { PostAuthorizeReactiveAuthorizationManager manager = new PostAuthorizeReactiveAuthorizationManager( expressionHandler); ReactiveAuthorizationManager authorizationManager = manager(manager, registryProvider); - defaultsObjectProvider.ifAvailable(manager::setTemplateDefaults); - return AuthorizationManagerAfterReactiveMethodInterceptor.postAuthorize(authorizationManager); + AuthorizationAdvisor interceptor = AuthorizationManagerAfterReactiveMethodInterceptor + .postAuthorize(authorizationManager); + return new DeferringMethodInterceptor<>(interceptor, + (i) -> defaultsObjectProvider.ifAvailable(manager::setTemplateDefaults)); } @Bean @@ -112,4 +125,50 @@ static ReactiveAuthorizationManager manager(ReactiveAuthorizationManager< return new DeferringObservationReactiveAuthorizationManager<>(registryProvider, delegate); } + private static final class DeferringMethodInterceptor + implements AuthorizationAdvisor { + + private final Pointcut pointcut; + + private final int order; + + private final Supplier delegate; + + DeferringMethodInterceptor(M delegate, Consumer supplier) { + this.pointcut = delegate.getPointcut(); + this.order = delegate.getOrder(); + this.delegate = SingletonSupplier.of(() -> { + supplier.accept(delegate); + return delegate; + }); + } + + @Nullable + @Override + public Object invoke(@NotNull MethodInvocation invocation) throws Throwable { + return this.delegate.get().invoke(invocation); + } + + @Override + public Pointcut getPointcut() { + return this.pointcut; + } + + @Override + public Advice getAdvice() { + return this; + } + + @Override + public int getOrder() { + return this.order; + } + + @Override + public boolean isPerInstance() { + return true; + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationProxyConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationProxyConfiguration.java new file mode 100644 index 00000000000..b7e3b07fbdc --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationProxyConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 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.config.annotation.method.configuration; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.aop.framework.AopInfrastructureBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.security.authorization.ReactiveAuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.method.AuthorizationAdvisor; + +@Configuration(proxyBeanMethods = false) +final class ReactiveAuthorizationProxyConfiguration implements AopInfrastructureBean { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static ReactiveAuthorizationAdvisorProxyFactory authorizationProxyFactory( + ObjectProvider provider) { + List advisors = new ArrayList<>(); + provider.forEach(advisors::add); + ReactiveAuthorizationAdvisorProxyFactory factory = new ReactiveAuthorizationAdvisorProxyFactory(); + factory.setAdvisors(advisors); + return factory; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java index dd984bc1a8f..b1c923383e5 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java @@ -51,13 +51,15 @@ public String[] selectImports(AnnotationMetadata importMetadata) { else { imports.add(ReactiveMethodSecurityConfiguration.class.getName()); } + imports.add(ReactiveAuthorizationProxyConfiguration.class.getName()); return imports.toArray(new String[0]); } private static final class AutoProxyRegistrarSelector extends AdviceModeImportSelector { - private static final String[] IMPORTS = new String[] { AutoProxyRegistrar.class.getName() }; + private static final String[] IMPORTS = new String[] { AutoProxyRegistrar.class.getName(), + MethodSecurityAdvisorRegistrar.class.getName() }; @Override protected String[] selectImports(@NonNull AdviceMode adviceMode) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java index a1909388781..2b6a2e29280 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java @@ -20,6 +20,7 @@ import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; @@ -48,7 +49,7 @@ */ @Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) -final class SecuredMethodSecurityConfiguration implements ImportAware { +final class SecuredMethodSecurityConfiguration implements ImportAware, AopInfrastructureBean { private int interceptorOrderOffset; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2ClientConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2ClientConfiguration.java new file mode 100644 index 00000000000..56e25bd70c9 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2ClientConfiguration.java @@ -0,0 +1,413 @@ +/* + * Copyright 2002-2024 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.config.annotation.web.reactive; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.context.annotation.AnnotationBeanNameGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.ResolvableType; +import org.springframework.security.oauth2.client.AuthorizationCodeReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.ClientCredentialsReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.DelegatingReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.JwtBearerReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.PasswordReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.RefreshTokenReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.TokenExchangeReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.TokenExchangeGrantRequest; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.reactive.result.method.annotation.OAuth2AuthorizedClientArgumentResolver; +import org.springframework.security.oauth2.client.web.server.AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; + +/** + * {@link Configuration} for OAuth 2.0 Client support. + * + *

+ * This {@code Configuration} is conditionally imported by + * {@link ReactiveOAuth2ClientImportSelector} when the + * {@code spring-security-oauth2-client} module is present on the classpath. + * + * @author Steve Riesenberg + * @since 6.3 + * @see ReactiveOAuth2ClientImportSelector + */ +@Import({ ReactiveOAuth2ClientConfiguration.ReactiveOAuth2AuthorizedClientManagerConfiguration.class, + ReactiveOAuth2ClientConfiguration.OAuth2ClientWebFluxSecurityConfiguration.class }) +final class ReactiveOAuth2ClientConfiguration { + + @Configuration + static class ReactiveOAuth2AuthorizedClientManagerConfiguration { + + @Bean(name = ReactiveOAuth2AuthorizedClientManagerRegistrar.BEAN_NAME) + ReactiveOAuth2AuthorizedClientManagerRegistrar authorizedClientManagerRegistrar() { + return new ReactiveOAuth2AuthorizedClientManagerRegistrar(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class OAuth2ClientWebFluxSecurityConfiguration implements WebFluxConfigurer { + + private ReactiveOAuth2AuthorizedClientManager authorizedClientManager; + + private ReactiveOAuth2AuthorizedClientManagerRegistrar authorizedClientManagerRegistrar; + + @Override + public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { + ReactiveOAuth2AuthorizedClientManager authorizedClientManager = getAuthorizedClientManager(); + if (authorizedClientManager != null) { + configurer.addCustomResolver(new OAuth2AuthorizedClientArgumentResolver(authorizedClientManager)); + } + } + + @Autowired(required = false) + void setAuthorizedClientManager(List authorizedClientManager) { + if (authorizedClientManager.size() == 1) { + this.authorizedClientManager = authorizedClientManager.get(0); + } + } + + @Autowired + void setAuthorizedClientManagerRegistrar( + ReactiveOAuth2AuthorizedClientManagerRegistrar authorizedClientManagerRegistrar) { + this.authorizedClientManagerRegistrar = authorizedClientManagerRegistrar; + } + + private ReactiveOAuth2AuthorizedClientManager getAuthorizedClientManager() { + if (this.authorizedClientManager != null) { + return this.authorizedClientManager; + } + return this.authorizedClientManagerRegistrar.getAuthorizedClientManagerIfAvailable(); + } + + } + + /** + * A registrar for registering the default + * {@link ReactiveOAuth2AuthorizedClientManager} bean definition, if not already + * present. + */ + static final class ReactiveOAuth2AuthorizedClientManagerRegistrar + implements BeanDefinitionRegistryPostProcessor, BeanFactoryAware { + + static final String BEAN_NAME = "authorizedClientManagerRegistrar"; + + static final String FACTORY_METHOD_NAME = "getAuthorizedClientManager"; + + // @formatter:off + private static final Set> KNOWN_AUTHORIZED_CLIENT_PROVIDERS = Set.of( + AuthorizationCodeReactiveOAuth2AuthorizedClientProvider.class, + RefreshTokenReactiveOAuth2AuthorizedClientProvider.class, + ClientCredentialsReactiveOAuth2AuthorizedClientProvider.class, + PasswordReactiveOAuth2AuthorizedClientProvider.class, + JwtBearerReactiveOAuth2AuthorizedClientProvider.class, + TokenExchangeReactiveOAuth2AuthorizedClientProvider.class + ); + // @formatter:on + + private final AnnotationBeanNameGenerator beanNameGenerator = new AnnotationBeanNameGenerator(); + + private ListableBeanFactory beanFactory; + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + if (getBeanNamesForType(ReactiveOAuth2AuthorizedClientManager.class).length != 0 + || getBeanNamesForType(ReactiveClientRegistrationRepository.class).length != 1 + || getBeanNamesForType(ServerOAuth2AuthorizedClientRepository.class).length != 1 + && getBeanNamesForType(ReactiveOAuth2AuthorizedClientService.class).length != 1) { + return; + } + + BeanDefinition beanDefinition = BeanDefinitionBuilder + .rootBeanDefinition(ReactiveOAuth2AuthorizedClientManager.class) + .setFactoryMethodOnBean(FACTORY_METHOD_NAME, BEAN_NAME) + .getBeanDefinition(); + + registry.registerBeanDefinition(this.beanNameGenerator.generateBeanName(beanDefinition, registry), + beanDefinition); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = (ListableBeanFactory) beanFactory; + } + + ReactiveOAuth2AuthorizedClientManager getAuthorizedClientManagerIfAvailable() { + if (getBeanNamesForType(ReactiveClientRegistrationRepository.class).length != 1 + || getBeanNamesForType(ServerOAuth2AuthorizedClientRepository.class).length != 1 + && getBeanNamesForType(ReactiveOAuth2AuthorizedClientService.class).length != 1) { + return null; + } + return getAuthorizedClientManager(); + } + + ReactiveOAuth2AuthorizedClientManager getAuthorizedClientManager() { + ReactiveClientRegistrationRepository clientRegistrationRepository = BeanFactoryUtils + .beanOfTypeIncludingAncestors(this.beanFactory, ReactiveClientRegistrationRepository.class, true, true); + + ServerOAuth2AuthorizedClientRepository authorizedClientRepository; + try { + authorizedClientRepository = BeanFactoryUtils.beanOfTypeIncludingAncestors(this.beanFactory, + ServerOAuth2AuthorizedClientRepository.class, true, true); + } + catch (NoSuchBeanDefinitionException ex) { + ReactiveOAuth2AuthorizedClientService authorizedClientService = BeanFactoryUtils + .beanOfTypeIncludingAncestors(this.beanFactory, ReactiveOAuth2AuthorizedClientService.class, true, + true); + authorizedClientRepository = new AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository( + authorizedClientService); + } + + Collection authorizedClientProviderBeans = BeanFactoryUtils + .beansOfTypeIncludingAncestors(this.beanFactory, ReactiveOAuth2AuthorizedClientProvider.class, true, + true) + .values(); + + ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider; + if (hasDelegatingAuthorizedClientProvider(authorizedClientProviderBeans)) { + authorizedClientProvider = authorizedClientProviderBeans.iterator().next(); + } + else { + List authorizedClientProviders = new ArrayList<>(); + authorizedClientProviders + .add(getAuthorizationCodeAuthorizedClientProvider(authorizedClientProviderBeans)); + authorizedClientProviders.add(getRefreshTokenAuthorizedClientProvider(authorizedClientProviderBeans)); + authorizedClientProviders + .add(getClientCredentialsAuthorizedClientProvider(authorizedClientProviderBeans)); + authorizedClientProviders.add(getPasswordAuthorizedClientProvider(authorizedClientProviderBeans)); + + ReactiveOAuth2AuthorizedClientProvider jwtBearerAuthorizedClientProvider = getJwtBearerAuthorizedClientProvider( + authorizedClientProviderBeans); + if (jwtBearerAuthorizedClientProvider != null) { + authorizedClientProviders.add(jwtBearerAuthorizedClientProvider); + } + + ReactiveOAuth2AuthorizedClientProvider tokenExchangeAuthorizedClientProvider = getTokenExchangeAuthorizedClientProvider( + authorizedClientProviderBeans); + if (tokenExchangeAuthorizedClientProvider != null) { + authorizedClientProviders.add(tokenExchangeAuthorizedClientProvider); + } + + authorizedClientProviders.addAll(getAdditionalAuthorizedClientProviders(authorizedClientProviderBeans)); + authorizedClientProvider = new DelegatingReactiveOAuth2AuthorizedClientProvider( + authorizedClientProviders); + } + + DefaultReactiveOAuth2AuthorizedClientManager authorizedClientManager = new DefaultReactiveOAuth2AuthorizedClientManager( + clientRegistrationRepository, authorizedClientRepository); + authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); + + Consumer authorizedClientManagerConsumer = getBeanOfType( + ResolvableType.forClassWithGenerics(Consumer.class, + DefaultReactiveOAuth2AuthorizedClientManager.class)); + if (authorizedClientManagerConsumer != null) { + authorizedClientManagerConsumer.accept(authorizedClientManager); + } + + return authorizedClientManager; + } + + private boolean hasDelegatingAuthorizedClientProvider( + Collection authorizedClientProviders) { + if (authorizedClientProviders.size() != 1) { + return false; + } + return authorizedClientProviders.iterator() + .next() instanceof DelegatingReactiveOAuth2AuthorizedClientProvider; + } + + private ReactiveOAuth2AuthorizedClientProvider getAuthorizationCodeAuthorizedClientProvider( + Collection authorizedClientProviders) { + AuthorizationCodeReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = getAuthorizedClientProviderByType( + authorizedClientProviders, AuthorizationCodeReactiveOAuth2AuthorizedClientProvider.class); + if (authorizedClientProvider == null) { + authorizedClientProvider = new AuthorizationCodeReactiveOAuth2AuthorizedClientProvider(); + } + + return authorizedClientProvider; + } + + private ReactiveOAuth2AuthorizedClientProvider getRefreshTokenAuthorizedClientProvider( + Collection authorizedClientProviders) { + RefreshTokenReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = getAuthorizedClientProviderByType( + authorizedClientProviders, RefreshTokenReactiveOAuth2AuthorizedClientProvider.class); + if (authorizedClientProvider == null) { + authorizedClientProvider = new RefreshTokenReactiveOAuth2AuthorizedClientProvider(); + } + + ReactiveOAuth2AccessTokenResponseClient accessTokenResponseClient = getBeanOfType( + ResolvableType.forClassWithGenerics(ReactiveOAuth2AccessTokenResponseClient.class, + OAuth2RefreshTokenGrantRequest.class)); + if (accessTokenResponseClient != null) { + authorizedClientProvider.setAccessTokenResponseClient(accessTokenResponseClient); + } + + return authorizedClientProvider; + } + + private ReactiveOAuth2AuthorizedClientProvider getClientCredentialsAuthorizedClientProvider( + Collection authorizedClientProviders) { + ClientCredentialsReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = getAuthorizedClientProviderByType( + authorizedClientProviders, ClientCredentialsReactiveOAuth2AuthorizedClientProvider.class); + if (authorizedClientProvider == null) { + authorizedClientProvider = new ClientCredentialsReactiveOAuth2AuthorizedClientProvider(); + } + + ReactiveOAuth2AccessTokenResponseClient accessTokenResponseClient = getBeanOfType( + ResolvableType.forClassWithGenerics(ReactiveOAuth2AccessTokenResponseClient.class, + OAuth2ClientCredentialsGrantRequest.class)); + if (accessTokenResponseClient != null) { + authorizedClientProvider.setAccessTokenResponseClient(accessTokenResponseClient); + } + + return authorizedClientProvider; + } + + private ReactiveOAuth2AuthorizedClientProvider getPasswordAuthorizedClientProvider( + Collection authorizedClientProviders) { + PasswordReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = getAuthorizedClientProviderByType( + authorizedClientProviders, PasswordReactiveOAuth2AuthorizedClientProvider.class); + if (authorizedClientProvider == null) { + authorizedClientProvider = new PasswordReactiveOAuth2AuthorizedClientProvider(); + } + + ReactiveOAuth2AccessTokenResponseClient accessTokenResponseClient = getBeanOfType( + ResolvableType.forClassWithGenerics(ReactiveOAuth2AccessTokenResponseClient.class, + OAuth2PasswordGrantRequest.class)); + if (accessTokenResponseClient != null) { + authorizedClientProvider.setAccessTokenResponseClient(accessTokenResponseClient); + } + + return authorizedClientProvider; + } + + private ReactiveOAuth2AuthorizedClientProvider getJwtBearerAuthorizedClientProvider( + Collection authorizedClientProviders) { + JwtBearerReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = getAuthorizedClientProviderByType( + authorizedClientProviders, JwtBearerReactiveOAuth2AuthorizedClientProvider.class); + + ReactiveOAuth2AccessTokenResponseClient accessTokenResponseClient = getBeanOfType( + ResolvableType.forClassWithGenerics(ReactiveOAuth2AccessTokenResponseClient.class, + JwtBearerGrantRequest.class)); + if (accessTokenResponseClient != null) { + if (authorizedClientProvider == null) { + authorizedClientProvider = new JwtBearerReactiveOAuth2AuthorizedClientProvider(); + } + + authorizedClientProvider.setAccessTokenResponseClient(accessTokenResponseClient); + } + + return authorizedClientProvider; + } + + private ReactiveOAuth2AuthorizedClientProvider getTokenExchangeAuthorizedClientProvider( + Collection authorizedClientProviders) { + TokenExchangeReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = getAuthorizedClientProviderByType( + authorizedClientProviders, TokenExchangeReactiveOAuth2AuthorizedClientProvider.class); + + ReactiveOAuth2AccessTokenResponseClient accessTokenResponseClient = getBeanOfType( + ResolvableType.forClassWithGenerics(ReactiveOAuth2AccessTokenResponseClient.class, + TokenExchangeGrantRequest.class)); + if (accessTokenResponseClient != null) { + if (authorizedClientProvider == null) { + authorizedClientProvider = new TokenExchangeReactiveOAuth2AuthorizedClientProvider(); + } + + authorizedClientProvider.setAccessTokenResponseClient(accessTokenResponseClient); + } + + return authorizedClientProvider; + } + + private List getAdditionalAuthorizedClientProviders( + Collection authorizedClientProviders) { + List additionalAuthorizedClientProviders = new ArrayList<>( + authorizedClientProviders); + additionalAuthorizedClientProviders + .removeIf((provider) -> KNOWN_AUTHORIZED_CLIENT_PROVIDERS.contains(provider.getClass())); + return additionalAuthorizedClientProviders; + } + + private T getAuthorizedClientProviderByType( + Collection authorizedClientProviders, Class providerClass) { + T authorizedClientProvider = null; + for (ReactiveOAuth2AuthorizedClientProvider current : authorizedClientProviders) { + if (providerClass.isInstance(current)) { + assertAuthorizedClientProviderIsNull(authorizedClientProvider); + authorizedClientProvider = providerClass.cast(current); + } + } + return authorizedClientProvider; + } + + private static void assertAuthorizedClientProviderIsNull( + ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider) { + if (authorizedClientProvider != null) { + // @formatter:off + throw new BeanInitializationException(String.format( + "Unable to create a %s bean. Expected one bean of type %s, but found multiple. " + + "Please consider defining only a single bean of this type, or define a %s bean yourself.", + ReactiveOAuth2AuthorizedClientManager.class.getName(), + authorizedClientProvider.getClass().getName(), + ReactiveOAuth2AuthorizedClientManager.class.getName())); + // @formatter:on + } + } + + private String[] getBeanNamesForType(Class beanClass) { + return BeanFactoryUtils.beanNamesForTypeIncludingAncestors(this.beanFactory, beanClass, true, true); + } + + private T getBeanOfType(ResolvableType resolvableType) { + ObjectProvider objectProvider = this.beanFactory.getBeanProvider(resolvableType, true); + return objectProvider.getIfAvailable(); + } + + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2ClientImportSelector.java b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2ClientImportSelector.java index 9a1781b93bf..5e73fb7c894 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2ClientImportSelector.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2ClientImportSelector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -16,27 +16,13 @@ package org.springframework.security.config.annotation.web.reactive; -import java.util.List; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportSelector; import org.springframework.core.type.AnnotationMetadata; -import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider; -import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder; -import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; -import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; -import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.client.web.reactive.result.method.annotation.OAuth2AuthorizedClientArgumentResolver; -import org.springframework.security.oauth2.client.web.server.AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository; -import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; import org.springframework.util.ClassUtils; -import org.springframework.web.reactive.config.WebFluxConfigurer; -import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; /** - * {@link Configuration} for OAuth 2.0 Client support. + * Used by {@link EnableWebFluxSecurity} to conditionally import + * {@link ReactiveOAuth2ClientConfiguration}. * *

* This {@code Configuration} is imported by {@link EnableWebFluxSecurity} @@ -60,85 +46,8 @@ public String[] selectImports(AnnotationMetadata importingClassMetadata) { if (!oauth2ClientPresent) { return new String[0]; } - return new String[] { "org.springframework.security.config.annotation.web.reactive." - + "ReactiveOAuth2ClientImportSelector$OAuth2ClientWebFluxSecurityConfiguration" }; - } - - @Configuration(proxyBeanMethods = false) - static class OAuth2ClientWebFluxSecurityConfiguration implements WebFluxConfigurer { - - private ReactiveClientRegistrationRepository clientRegistrationRepository; - - private ServerOAuth2AuthorizedClientRepository authorizedClientRepository; - - private ReactiveOAuth2AuthorizedClientService authorizedClientService; - - private ReactiveOAuth2AuthorizedClientManager authorizedClientManager; - - @Override - public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { - ReactiveOAuth2AuthorizedClientManager authorizedClientManager = getAuthorizedClientManager(); - if (authorizedClientManager != null) { - configurer.addCustomResolver(new OAuth2AuthorizedClientArgumentResolver(authorizedClientManager)); - } - } - - @Autowired(required = false) - void setClientRegistrationRepository(ReactiveClientRegistrationRepository clientRegistrationRepository) { - this.clientRegistrationRepository = clientRegistrationRepository; - } - - @Autowired(required = false) - void setAuthorizedClientRepository(ServerOAuth2AuthorizedClientRepository authorizedClientRepository) { - this.authorizedClientRepository = authorizedClientRepository; - } - - @Autowired(required = false) - void setAuthorizedClientService(List authorizedClientService) { - if (authorizedClientService.size() == 1) { - this.authorizedClientService = authorizedClientService.get(0); - } - } - - @Autowired(required = false) - void setAuthorizedClientManager(List authorizedClientManager) { - if (authorizedClientManager.size() == 1) { - this.authorizedClientManager = authorizedClientManager.get(0); - } - } - - private ServerOAuth2AuthorizedClientRepository getAuthorizedClientRepository() { - if (this.authorizedClientRepository != null) { - return this.authorizedClientRepository; - } - if (this.authorizedClientService != null) { - return new AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository(this.authorizedClientService); - } - return null; - } - - private ReactiveOAuth2AuthorizedClientManager getAuthorizedClientManager() { - if (this.authorizedClientManager != null) { - return this.authorizedClientManager; - } - ReactiveOAuth2AuthorizedClientManager authorizedClientManager = null; - if (this.authorizedClientRepository != null && this.clientRegistrationRepository != null) { - ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = ReactiveOAuth2AuthorizedClientProviderBuilder - .builder() - .authorizationCode() - .refreshToken() - .clientCredentials() - .password() - .build(); - DefaultReactiveOAuth2AuthorizedClientManager defaultReactiveOAuth2AuthorizedClientManager = new DefaultReactiveOAuth2AuthorizedClientManager( - this.clientRegistrationRepository, getAuthorizedClientRepository()); - defaultReactiveOAuth2AuthorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); - authorizedClientManager = defaultReactiveOAuth2AuthorizedClientManager; - } - - return authorizedClientManager; - } - + return new String[] { + "org.springframework.security.config.annotation.web.reactive.ReactiveOAuth2ClientConfiguration" }; } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfigurationTests.java new file mode 100644 index 00000000000..254c8b08713 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfigurationTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2024 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.config.annotation.method.configuration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.authorization.AuthorizationProxyFactory; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +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.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link PrePostMethodSecurityConfiguration}. + * + * @author Evgeniy Cheban + * @author Josh Cummings + */ +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@SecurityTestExecutionListeners +public class AuthorizationProxyConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + AuthorizationProxyFactory proxyFactory; + + @WithMockUser + @Test + public void proxyWhenNotPreAuthorizedThenDenies() { + this.spring.register(DefaultsConfig.class).autowire(); + Toaster toaster = (Toaster) this.proxyFactory.proxy(new Toaster()); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(toaster::makeToast) + .withMessage("Access Denied"); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(toaster::extractBread) + .withMessage("Access Denied"); + } + + @WithMockUser(roles = "ADMIN") + @Test + public void proxyWhenPreAuthorizedThenAllows() { + this.spring.register(DefaultsConfig.class).autowire(); + Toaster toaster = (Toaster) this.proxyFactory.proxy(new Toaster()); + toaster.makeToast(); + assertThat(toaster.extractBread()).isEqualTo("yummy"); + } + + @Test + public void proxyReactiveWhenNotPreAuthorizedThenDenies() { + this.spring.register(ReactiveDefaultsConfig.class).autowire(); + Toaster toaster = (Toaster) this.proxyFactory.proxy(new Toaster()); + Authentication user = TestAuthentication.authenticatedUser(); + StepVerifier + .create(toaster.reactiveMakeToast().contextWrite(ReactiveSecurityContextHolder.withAuthentication(user))) + .verifyError(AccessDeniedException.class); + StepVerifier + .create(toaster.reactiveExtractBread().contextWrite(ReactiveSecurityContextHolder.withAuthentication(user))) + .verifyError(AccessDeniedException.class); + } + + @Test + public void proxyReactiveWhenPreAuthorizedThenAllows() { + this.spring.register(ReactiveDefaultsConfig.class).autowire(); + Toaster toaster = (Toaster) this.proxyFactory.proxy(new Toaster()); + Authentication admin = TestAuthentication.authenticatedAdmin(); + StepVerifier + .create(toaster.reactiveMakeToast().contextWrite(ReactiveSecurityContextHolder.withAuthentication(admin))) + .expectNext() + .verifyComplete(); + } + + @EnableMethodSecurity + @Configuration + static class DefaultsConfig { + + } + + @EnableReactiveMethodSecurity + @Configuration + static class ReactiveDefaultsConfig { + + } + + static class Toaster { + + @PreAuthorize("hasRole('ADMIN')") + void makeToast() { + + } + + @PostAuthorize("hasRole('ADMIN')") + String extractBread() { + return "yummy"; + } + + @PreAuthorize("hasRole('ADMIN')") + Mono reactiveMakeToast() { + return Mono.empty(); + } + + @PostAuthorize("hasRole('ADMIN')") + Mono reactiveExtractBread() { + return Mono.just("yummy"); + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizedClientManagerConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizedClientManagerConfigurationTests.java index 4c1fd5f2daf..7e58ce5b8e4 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizedClientManagerConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizedClientManagerConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -38,8 +38,6 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.oauth2.client.AuthorizationCodeOAuth2AuthorizedClientProvider; @@ -61,14 +59,9 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; import org.springframework.security.oauth2.client.endpoint.TokenExchangeGrantRequest; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -80,13 +73,10 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.TestOAuth2AccessTokenResponses; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.jwt.JoseHeaderNames; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.security.web.SecurityFilterChain; import org.springframework.util.StringUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -397,42 +387,32 @@ static class CustomAccessTokenResponseClientsConfig extends OAuth2ClientBaseConf @Bean OAuth2AccessTokenResponseClient authorizationCodeTokenResponseClient() { - return new MockAuthorizationCodeClient(); + return new MockAccessTokenResponseClient<>(); } @Bean OAuth2AccessTokenResponseClient refreshTokenTokenResponseClient() { - return new MockRefreshTokenClient(); + return new MockAccessTokenResponseClient<>(); } @Bean OAuth2AccessTokenResponseClient clientCredentialsTokenResponseClient() { - return new MockClientCredentialsClient(); + return new MockAccessTokenResponseClient<>(); } @Bean OAuth2AccessTokenResponseClient passwordTokenResponseClient() { - return new MockPasswordClient(); + return new MockAccessTokenResponseClient<>(); } @Bean OAuth2AccessTokenResponseClient jwtBearerTokenResponseClient() { - return new MockJwtBearerClient(); + return new MockAccessTokenResponseClient<>(); } @Bean OAuth2AccessTokenResponseClient tokenExchangeTokenResponseClient() { - return new MockTokenExchangeClient(); - } - - @Bean - OAuth2UserService oauth2UserService() { - return mock(DefaultOAuth2UserService.class); - } - - @Bean - OAuth2UserService oidcUserService() { - return mock(OidcUserService.class); + return new MockAccessTokenResponseClient<>(); } } @@ -449,35 +429,35 @@ AuthorizationCodeOAuth2AuthorizedClientProvider authorizationCodeProvider() { @Bean RefreshTokenOAuth2AuthorizedClientProvider refreshTokenProvider() { RefreshTokenOAuth2AuthorizedClientProvider authorizedClientProvider = new RefreshTokenOAuth2AuthorizedClientProvider(); - authorizedClientProvider.setAccessTokenResponseClient(new MockRefreshTokenClient()); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); return authorizedClientProvider; } @Bean ClientCredentialsOAuth2AuthorizedClientProvider clientCredentialsProvider() { ClientCredentialsOAuth2AuthorizedClientProvider authorizedClientProvider = new ClientCredentialsOAuth2AuthorizedClientProvider(); - authorizedClientProvider.setAccessTokenResponseClient(new MockClientCredentialsClient()); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); return authorizedClientProvider; } @Bean PasswordOAuth2AuthorizedClientProvider passwordProvider() { PasswordOAuth2AuthorizedClientProvider authorizedClientProvider = new PasswordOAuth2AuthorizedClientProvider(); - authorizedClientProvider.setAccessTokenResponseClient(new MockPasswordClient()); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); return authorizedClientProvider; } @Bean JwtBearerOAuth2AuthorizedClientProvider jwtBearerAuthorizedClientProvider() { JwtBearerOAuth2AuthorizedClientProvider authorizedClientProvider = new JwtBearerOAuth2AuthorizedClientProvider(); - authorizedClientProvider.setAccessTokenResponseClient(new MockJwtBearerClient()); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); return authorizedClientProvider; } @Bean TokenExchangeOAuth2AuthorizedClientProvider tokenExchangeAuthorizedClientProvider() { TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider = new TokenExchangeOAuth2AuthorizedClientProvider(); - authorizedClientProvider.setAccessTokenResponseClient(new MockTokenExchangeClient()); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); return authorizedClientProvider; } @@ -485,21 +465,10 @@ TokenExchangeOAuth2AuthorizedClientProvider tokenExchangeAuthorizedClientProvide abstract static class OAuth2ClientBaseConfig { - @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - // @formatter:off - http - .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) - .oauth2Login(Customizer.withDefaults()) - .oauth2Client(Customizer.withDefaults()); - return http.build(); - // @formatter:on - } - @Bean ClientRegistrationRepository clientRegistrationRepository() { // @formatter:off - return new InMemoryClientRegistrationRepository(Arrays.asList( + return new InMemoryClientRegistrationRepository( CommonOAuth2Provider.GOOGLE.getBuilder("google") .clientId("google-client-id") .clientSecret("google-client-secret") @@ -527,7 +496,7 @@ ClientRegistrationRepository clientRegistrationRepository() { .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) .scope("user.read", "user.write") - .build())); + .build()); // @formatter:on } @@ -558,60 +527,11 @@ Consumer authorizedClientManagerConsumer() } - private static class MockAuthorizationCodeClient - implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse( - OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockRefreshTokenClient - implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse(OAuth2RefreshTokenGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockClientCredentialsClient - implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse( - OAuth2ClientCredentialsGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockPasswordClient implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse(OAuth2PasswordGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockJwtBearerClient implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse(JwtBearerGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockTokenExchangeClient implements OAuth2AccessTokenResponseClient { + private static class MockAccessTokenResponseClient + implements OAuth2AccessTokenResponseClient { @Override - public OAuth2AccessTokenResponse getTokenResponse(TokenExchangeGrantRequest authorizationGrantRequest) { + public OAuth2AccessTokenResponse getTokenResponse(T authorizationGrantRequest) { return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2AuthorizedClientManagerConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2AuthorizedClientManagerConfigurationTests.java new file mode 100644 index 00000000000..dd7698e98db --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/reactive/ReactiveOAuth2AuthorizedClientManagerConfigurationTests.java @@ -0,0 +1,589 @@ +/* + * Copyright 2002-2024 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.config.annotation.web.reactive; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.oauth2.client.AuthorizationCodeReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.ClientAuthorizationRequiredException; +import org.springframework.security.oauth2.client.ClientCredentialsReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.JwtBearerReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.OAuth2AuthorizationContext; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.PasswordReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.RefreshTokenReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.TokenExchangeReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest; +import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.TokenExchangeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.TestOAuth2RefreshTokens; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.TestOAuth2AccessTokenResponses; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +/** + * Tests for + * {@link ReactiveOAuth2ClientConfiguration.ReactiveOAuth2AuthorizedClientManagerConfiguration}. + * + * @author Steve Riesenberg + */ +public class ReactiveOAuth2AuthorizedClientManagerConfigurationTests { + + private static ReactiveOAuth2AccessTokenResponseClient MOCK_RESPONSE_CLIENT; + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + private ReactiveOAuth2AuthorizedClientManager authorizedClientManager; + + @Autowired + private ReactiveClientRegistrationRepository clientRegistrationRepository; + + @Autowired(required = false) + private ServerOAuth2AuthorizedClientRepository authorizedClientRepository; + + @Autowired(required = false) + private ReactiveOAuth2AuthorizedClientService authorizedClientService; + + @Autowired(required = false) + private AuthorizationCodeReactiveOAuth2AuthorizedClientProvider authorizationCodeAuthorizedClientProvider; + + private MockServerWebExchange exchange; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setUp() { + MOCK_RESPONSE_CLIENT = mock(ReactiveOAuth2AccessTokenResponseClient.class); + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + this.exchange = MockServerWebExchange.builder(request).build(); + } + + @Test + public void loadContextWhenOAuth2ClientEnabledThenConfigured() { + this.spring.register(MinimalOAuth2ClientConfig.class).autowire(); + assertThat(this.authorizedClientManager).isNotNull(); + } + + @Test + public void authorizeWhenAuthorizationCodeAuthorizedClientProviderBeanThenUsed() { + this.spring.register(CustomAuthorizedClientProvidersConfig.class).autowire(); + + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", null, "ROLE_USER"); + // @formatter:off + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest + .withClientRegistrationId("google") + .principal(authentication) + .attribute(ServerWebExchange.class.getName(), this.exchange) + .build(); + assertThatExceptionOfType(ClientAuthorizationRequiredException.class) + .isThrownBy(() -> this.authorizedClientManager.authorize(authorizeRequest).block()) + .extracting(OAuth2AuthorizationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo("client_authorization_required"); + // @formatter:on + + verify(this.authorizationCodeAuthorizedClientProvider).authorize(any(OAuth2AuthorizationContext.class)); + } + + @Test + public void authorizeWhenAuthorizedClientServiceBeanThenUsed() { + this.spring.register(CustomAuthorizedClientServiceConfig.class).autowire(); + + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", null, "ROLE_USER"); + // @formatter:off + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest + .withClientRegistrationId("google") + .principal(authentication) + .attribute(ServerWebExchange.class.getName(), this.exchange) + .build(); + assertThatExceptionOfType(ClientAuthorizationRequiredException.class) + .isThrownBy(() -> this.authorizedClientManager.authorize(authorizeRequest).block()) + .extracting(OAuth2AuthorizationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo("client_authorization_required"); + // @formatter:on + + verify(this.authorizedClientService).loadAuthorizedClient(authorizeRequest.getClientRegistrationId(), + authentication.getName()); + } + + @Test + public void authorizeWhenRefreshTokenAccessTokenResponseClientBeanThenUsed() { + this.spring.register(CustomAccessTokenResponseClientsConfig.class).autowire(); + testRefreshTokenGrant(); + } + + @Test + public void authorizeWhenRefreshTokenAuthorizedClientProviderBeanThenUsed() { + this.spring.register(CustomAuthorizedClientProvidersConfig.class).autowire(); + testRefreshTokenGrant(); + } + + private void testRefreshTokenGrant() { + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(MOCK_RESPONSE_CLIENT.getTokenResponse(any(OAuth2RefreshTokenGrantRequest.class))) + .willReturn(Mono.just(accessTokenResponse)); + + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", null, "ROLE_USER"); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("google") + .block(); + assertThat(clientRegistration).isNotNull(); + OAuth2AuthorizedClient existingAuthorizedClient = new OAuth2AuthorizedClient(clientRegistration, + authentication.getName(), getExpiredAccessToken(), TestOAuth2RefreshTokens.refreshToken()); + this.authorizedClientRepository.saveAuthorizedClient(existingAuthorizedClient, authentication, this.exchange) + .block(); + // @formatter:off + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest + .withClientRegistrationId(clientRegistration.getRegistrationId()) + .principal(authentication) + .attribute(ServerWebExchange.class.getName(), this.exchange) + .build(); + // @formatter:on + OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest).block(); + assertThat(authorizedClient).isNotNull(); + + ArgumentCaptor grantRequestCaptor = ArgumentCaptor + .forClass(OAuth2RefreshTokenGrantRequest.class); + verify(MOCK_RESPONSE_CLIENT).getTokenResponse(grantRequestCaptor.capture()); + + OAuth2RefreshTokenGrantRequest grantRequest = grantRequestCaptor.getValue(); + assertThat(grantRequest.getClientRegistration().getRegistrationId()) + .isEqualTo(clientRegistration.getRegistrationId()); + assertThat(grantRequest.getGrantType()).isEqualTo(AuthorizationGrantType.REFRESH_TOKEN); + assertThat(grantRequest.getAccessToken()).isEqualTo(existingAuthorizedClient.getAccessToken()); + assertThat(grantRequest.getRefreshToken()).isEqualTo(existingAuthorizedClient.getRefreshToken()); + } + + @Test + public void authorizeWhenClientCredentialsAccessTokenResponseClientBeanThenUsed() { + this.spring.register(CustomAccessTokenResponseClientsConfig.class).autowire(); + testClientCredentialsGrant(); + } + + @Test + public void authorizeWhenClientCredentialsAuthorizedClientProviderBeanThenUsed() { + this.spring.register(CustomAuthorizedClientProvidersConfig.class).autowire(); + testClientCredentialsGrant(); + } + + private void testClientCredentialsGrant() { + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(MOCK_RESPONSE_CLIENT.getTokenResponse(any(OAuth2ClientCredentialsGrantRequest.class))) + .willReturn(Mono.just(accessTokenResponse)); + + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", null, "ROLE_USER"); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("github") + .block(); + assertThat(clientRegistration).isNotNull(); + // @formatter:off + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest + .withClientRegistrationId(clientRegistration.getRegistrationId()) + .principal(authentication) + .attribute(ServerWebExchange.class.getName(), this.exchange) + .build(); + // @formatter:on + OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest).block(); + assertThat(authorizedClient).isNotNull(); + + ArgumentCaptor grantRequestCaptor = ArgumentCaptor + .forClass(OAuth2ClientCredentialsGrantRequest.class); + verify(MOCK_RESPONSE_CLIENT).getTokenResponse(grantRequestCaptor.capture()); + + OAuth2ClientCredentialsGrantRequest grantRequest = grantRequestCaptor.getValue(); + assertThat(grantRequest.getClientRegistration().getRegistrationId()) + .isEqualTo(clientRegistration.getRegistrationId()); + assertThat(grantRequest.getGrantType()).isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS); + } + + @Test + public void authorizeWhenPasswordAccessTokenResponseClientBeanThenUsed() { + this.spring.register(CustomAccessTokenResponseClientsConfig.class).autowire(); + testPasswordGrant(); + } + + @Test + public void authorizeWhenPasswordAuthorizedClientProviderBeanThenUsed() { + this.spring.register(CustomAuthorizedClientProvidersConfig.class).autowire(); + testPasswordGrant(); + } + + private void testPasswordGrant() { + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(MOCK_RESPONSE_CLIENT.getTokenResponse(any(OAuth2PasswordGrantRequest.class))) + .willReturn(Mono.just(accessTokenResponse)); + + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("facebook") + .block(); + assertThat(clientRegistration).isNotNull(); + MockServerHttpRequest request = MockServerHttpRequest.post("/") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body("username=user&password=password"); + this.exchange = MockServerWebExchange.builder(request).build(); + // @formatter:off + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest + .withClientRegistrationId(clientRegistration.getRegistrationId()) + .principal(authentication) + .attribute(ServerWebExchange.class.getName(), this.exchange) + .build(); + // @formatter:on + OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest).block(); + assertThat(authorizedClient).isNotNull(); + + ArgumentCaptor grantRequestCaptor = ArgumentCaptor + .forClass(OAuth2PasswordGrantRequest.class); + verify(MOCK_RESPONSE_CLIENT).getTokenResponse(grantRequestCaptor.capture()); + + OAuth2PasswordGrantRequest grantRequest = grantRequestCaptor.getValue(); + assertThat(grantRequest.getClientRegistration().getRegistrationId()) + .isEqualTo(clientRegistration.getRegistrationId()); + assertThat(grantRequest.getGrantType()).isEqualTo(AuthorizationGrantType.PASSWORD); + assertThat(grantRequest.getUsername()).isEqualTo("user"); + assertThat(grantRequest.getPassword()).isEqualTo("password"); + } + + @Test + public void authorizeWhenJwtBearerAccessTokenResponseClientBeanThenUsed() { + this.spring.register(CustomAccessTokenResponseClientsConfig.class).autowire(); + testJwtBearerGrant(); + } + + @Test + public void authorizeWhenJwtBearerAuthorizedClientProviderBeanThenUsed() { + this.spring.register(CustomAuthorizedClientProvidersConfig.class).autowire(); + testJwtBearerGrant(); + } + + private void testJwtBearerGrant() { + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(MOCK_RESPONSE_CLIENT.getTokenResponse(any(JwtBearerGrantRequest.class))) + .willReturn(Mono.just(accessTokenResponse)); + + JwtAuthenticationToken authentication = new JwtAuthenticationToken(getJwt()); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("okta").block(); + assertThat(clientRegistration).isNotNull(); + // @formatter:off + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest + .withClientRegistrationId(clientRegistration.getRegistrationId()) + .principal(authentication) + .attribute(ServerWebExchange.class.getName(), this.exchange) + .build(); + // @formatter:on + OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest).block(); + assertThat(authorizedClient).isNotNull(); + + ArgumentCaptor grantRequestCaptor = ArgumentCaptor.forClass(JwtBearerGrantRequest.class); + verify(MOCK_RESPONSE_CLIENT).getTokenResponse(grantRequestCaptor.capture()); + + JwtBearerGrantRequest grantRequest = grantRequestCaptor.getValue(); + assertThat(grantRequest.getClientRegistration().getRegistrationId()) + .isEqualTo(clientRegistration.getRegistrationId()); + assertThat(grantRequest.getGrantType()).isEqualTo(AuthorizationGrantType.JWT_BEARER); + assertThat(grantRequest.getJwt().getSubject()).isEqualTo("user"); + } + + @Test + public void authorizeWhenTokenExchangeAccessTokenResponseClientBeanThenUsed() { + this.spring.register(CustomAccessTokenResponseClientsConfig.class).autowire(); + testTokenExchangeGrant(); + } + + @Test + public void authorizeWhenTokenExchangeAuthorizedClientProviderBeanThenUsed() { + this.spring.register(CustomAuthorizedClientProvidersConfig.class).autowire(); + testTokenExchangeGrant(); + } + + private void testTokenExchangeGrant() { + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(MOCK_RESPONSE_CLIENT.getTokenResponse(any(TokenExchangeGrantRequest.class))) + .willReturn(Mono.just(accessTokenResponse)); + + JwtAuthenticationToken authentication = new JwtAuthenticationToken(getJwt()); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("auth0").block(); + assertThat(clientRegistration).isNotNull(); + // @formatter:off + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest + .withClientRegistrationId(clientRegistration.getRegistrationId()) + .principal(authentication) + .attribute(ServerWebExchange.class.getName(), this.exchange) + .build(); + // @formatter:on + OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest).block(); + assertThat(authorizedClient).isNotNull(); + + ArgumentCaptor grantRequestCaptor = ArgumentCaptor + .forClass(TokenExchangeGrantRequest.class); + verify(MOCK_RESPONSE_CLIENT).getTokenResponse(grantRequestCaptor.capture()); + + TokenExchangeGrantRequest grantRequest = grantRequestCaptor.getValue(); + assertThat(grantRequest.getClientRegistration().getRegistrationId()) + .isEqualTo(clientRegistration.getRegistrationId()); + assertThat(grantRequest.getGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(grantRequest.getSubjectToken()).isEqualTo(authentication.getToken()); + } + + private static OAuth2AccessToken getExpiredAccessToken() { + Instant expiresAt = Instant.now().minusSeconds(60); + Instant issuedAt = expiresAt.minus(Duration.ofDays(1)); + return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "scopes", issuedAt, expiresAt, + new HashSet<>(Arrays.asList("read", "write"))); + } + + private static Jwt getJwt() { + Instant issuedAt = Instant.now(); + return new Jwt("token", issuedAt, issuedAt.plusSeconds(300), + Collections.singletonMap(JoseHeaderNames.ALG, "RS256"), + Collections.singletonMap(JwtClaimNames.SUB, "user")); + } + + @Configuration + @EnableWebFluxSecurity + static class MinimalOAuth2ClientConfig extends OAuth2ClientBaseConfig { + + @Bean + ServerOAuth2AuthorizedClientRepository authorizedClientRepository() { + return new WebSessionServerOAuth2AuthorizedClientRepository(); + } + + } + + @Configuration + @EnableWebFluxSecurity + static class CustomAuthorizedClientServiceConfig extends OAuth2ClientBaseConfig { + + @Bean + ReactiveOAuth2AuthorizedClientService authorizedClientService() { + ReactiveOAuth2AuthorizedClientService authorizedClientService = mock( + ReactiveOAuth2AuthorizedClientService.class); + given(authorizedClientService.loadAuthorizedClient(anyString(), anyString())).willReturn(Mono.empty()); + return authorizedClientService; + } + + } + + @Configuration + @EnableWebFluxSecurity + static class CustomAccessTokenResponseClientsConfig extends MinimalOAuth2ClientConfig { + + @Bean + ReactiveOAuth2AccessTokenResponseClient authorizationCodeAccessTokenResponseClient() { + return new MockAccessTokenResponseClient<>(); + } + + @Bean + ReactiveOAuth2AccessTokenResponseClient refreshTokenTokenAccessResponseClient() { + return new MockAccessTokenResponseClient<>(); + } + + @Bean + ReactiveOAuth2AccessTokenResponseClient clientCredentialsAccessTokenResponseClient() { + return new MockAccessTokenResponseClient<>(); + } + + @Bean + ReactiveOAuth2AccessTokenResponseClient passwordAccessTokenResponseClient() { + return new MockAccessTokenResponseClient<>(); + } + + @Bean + ReactiveOAuth2AccessTokenResponseClient jwtBearerAccessTokenResponseClient() { + return new MockAccessTokenResponseClient<>(); + } + + @Bean + ReactiveOAuth2AccessTokenResponseClient tokenExchangeAccessTokenResponseClient() { + return new MockAccessTokenResponseClient<>(); + } + + } + + @Configuration + @EnableWebFluxSecurity + static class CustomAuthorizedClientProvidersConfig extends MinimalOAuth2ClientConfig { + + @Bean + AuthorizationCodeReactiveOAuth2AuthorizedClientProvider authorizationCode() { + return spy(new AuthorizationCodeReactiveOAuth2AuthorizedClientProvider()); + } + + @Bean + RefreshTokenReactiveOAuth2AuthorizedClientProvider refreshToken() { + RefreshTokenReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = new RefreshTokenReactiveOAuth2AuthorizedClientProvider(); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); + return authorizedClientProvider; + } + + @Bean + ClientCredentialsReactiveOAuth2AuthorizedClientProvider clientCredentials() { + ClientCredentialsReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = new ClientCredentialsReactiveOAuth2AuthorizedClientProvider(); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); + return authorizedClientProvider; + } + + @Bean + PasswordReactiveOAuth2AuthorizedClientProvider password() { + PasswordReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = new PasswordReactiveOAuth2AuthorizedClientProvider(); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); + return authorizedClientProvider; + } + + @Bean + JwtBearerReactiveOAuth2AuthorizedClientProvider jwtBearer() { + JwtBearerReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = new JwtBearerReactiveOAuth2AuthorizedClientProvider(); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); + return authorizedClientProvider; + } + + @Bean + TokenExchangeReactiveOAuth2AuthorizedClientProvider tokenExchange() { + TokenExchangeReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = new TokenExchangeReactiveOAuth2AuthorizedClientProvider(); + authorizedClientProvider.setAccessTokenResponseClient(new MockAccessTokenResponseClient<>()); + return authorizedClientProvider; + } + + } + + abstract static class OAuth2ClientBaseConfig { + + @Bean + ReactiveClientRegistrationRepository clientRegistrationRepository() { + // @formatter:off + return new InMemoryReactiveClientRegistrationRepository( + CommonOAuth2Provider.GOOGLE.getBuilder("google") + .clientId("google-client-id") + .clientSecret("google-client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .build(), + CommonOAuth2Provider.GITHUB.getBuilder("github") + .clientId("github-client-id") + .clientSecret("github-client-secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(), + CommonOAuth2Provider.FACEBOOK.getBuilder("facebook") + .clientId("facebook-client-id") + .clientSecret("facebook-client-secret") + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .build(), + CommonOAuth2Provider.OKTA.getBuilder("okta") + .clientId("okta-client-id") + .clientSecret("okta-client-secret") + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER) + .build(), + ClientRegistration.withRegistrationId("auth0") + .clientName("Auth0") + .clientId("auth0-client-id") + .clientSecret("auth0-client-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) + .scope("user.read", "user.write") + .build()); + // @formatter:on + } + + @Bean + Consumer authorizedClientManagerConsumer() { + return (authorizedClientManager) -> authorizedClientManager + .setContextAttributesMapper((authorizeRequest) -> { + ServerWebExchange exchange = Objects + .requireNonNull(authorizeRequest.getAttribute(ServerWebExchange.class.getName())); + return exchange.getFormData().map((parameters) -> { + String username = parameters.getFirst(OAuth2ParameterNames.USERNAME); + String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD); + + Map attributes = Collections.emptyMap(); + if (StringUtils.hasText(username) && StringUtils.hasText(password)) { + attributes = new HashMap<>(); + attributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, username); + attributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password); + } + + return attributes; + }); + }); + + } + + } + + private static class MockAccessTokenResponseClient + implements ReactiveOAuth2AccessTokenResponseClient { + + @Override + public Mono getTokenResponse(T grantRequest) { + return MOCK_RESPONSE_CLIENT.getTokenResponse(grantRequest); + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests.java b/config/src/test/java/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests.java index e80444609a1..adeb9d8cb07 100644 --- a/config/src/test/java/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests.java +++ b/config/src/test/java/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -53,7 +53,6 @@ import org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest; import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; -import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; @@ -430,114 +429,65 @@ public static Consumer authorizedClientMan }); } - public static AuthorizationCodeOAuth2AuthorizedClientProvider authorizationCodeAuthorizedClientProvider() { + public static AuthorizationCodeOAuth2AuthorizedClientProvider authorizationCode() { return spy(new AuthorizationCodeOAuth2AuthorizedClientProvider()); } - public static RefreshTokenOAuth2AuthorizedClientProvider refreshTokenAuthorizedClientProvider() { + public static RefreshTokenOAuth2AuthorizedClientProvider refreshToken() { RefreshTokenOAuth2AuthorizedClientProvider authorizedClientProvider = new RefreshTokenOAuth2AuthorizedClientProvider(); authorizedClientProvider.setAccessTokenResponseClient(refreshTokenAccessTokenResponseClient()); return authorizedClientProvider; } - public static MockRefreshTokenClient refreshTokenAccessTokenResponseClient() { - return new MockRefreshTokenClient(); + public static OAuth2AccessTokenResponseClient refreshTokenAccessTokenResponseClient() { + return new MockAccessTokenResponseClient<>(); } - public static ClientCredentialsOAuth2AuthorizedClientProvider clientCredentialsAuthorizedClientProvider() { + public static ClientCredentialsOAuth2AuthorizedClientProvider clientCredentials() { ClientCredentialsOAuth2AuthorizedClientProvider authorizedClientProvider = new ClientCredentialsOAuth2AuthorizedClientProvider(); authorizedClientProvider.setAccessTokenResponseClient(clientCredentialsAccessTokenResponseClient()); return authorizedClientProvider; } public static OAuth2AccessTokenResponseClient clientCredentialsAccessTokenResponseClient() { - return new MockClientCredentialsClient(); + return new MockAccessTokenResponseClient<>(); } - public static PasswordOAuth2AuthorizedClientProvider passwordAuthorizedClientProvider() { + public static PasswordOAuth2AuthorizedClientProvider password() { PasswordOAuth2AuthorizedClientProvider authorizedClientProvider = new PasswordOAuth2AuthorizedClientProvider(); authorizedClientProvider.setAccessTokenResponseClient(passwordAccessTokenResponseClient()); return authorizedClientProvider; } public static OAuth2AccessTokenResponseClient passwordAccessTokenResponseClient() { - return new MockPasswordClient(); + return new MockAccessTokenResponseClient<>(); } - public static JwtBearerOAuth2AuthorizedClientProvider jwtBearerAuthorizedClientProvider() { + public static JwtBearerOAuth2AuthorizedClientProvider jwtBearer() { JwtBearerOAuth2AuthorizedClientProvider authorizedClientProvider = new JwtBearerOAuth2AuthorizedClientProvider(); authorizedClientProvider.setAccessTokenResponseClient(jwtBearerAccessTokenResponseClient()); return authorizedClientProvider; } public static OAuth2AccessTokenResponseClient jwtBearerAccessTokenResponseClient() { - return new MockJwtBearerClient(); + return new MockAccessTokenResponseClient<>(); } - public static TokenExchangeOAuth2AuthorizedClientProvider tokenExchangeAuthorizedClientProvider() { + public static TokenExchangeOAuth2AuthorizedClientProvider tokenExchange() { TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider = new TokenExchangeOAuth2AuthorizedClientProvider(); authorizedClientProvider.setAccessTokenResponseClient(tokenExchangeAccessTokenResponseClient()); return authorizedClientProvider; } public static OAuth2AccessTokenResponseClient tokenExchangeAccessTokenResponseClient() { - return new MockTokenExchangeClient(); + return new MockAccessTokenResponseClient<>(); } - private static class MockAuthorizationCodeClient - implements OAuth2AccessTokenResponseClient { + private static class MockAccessTokenResponseClient + implements OAuth2AccessTokenResponseClient { @Override - public OAuth2AccessTokenResponse getTokenResponse( - OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockRefreshTokenClient - implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse(OAuth2RefreshTokenGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockClientCredentialsClient - implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse( - OAuth2ClientCredentialsGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockPasswordClient implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse(OAuth2PasswordGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockJwtBearerClient implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse(JwtBearerGrantRequest authorizationGrantRequest) { - return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); - } - - } - - private static class MockTokenExchangeClient implements OAuth2AccessTokenResponseClient { - - @Override - public OAuth2AccessTokenResponse getTokenResponse(TokenExchangeGrantRequest authorizationGrantRequest) { + public OAuth2AccessTokenResponse getTokenResponse(T authorizationGrantRequest) { return MOCK_RESPONSE_CLIENT.getTokenResponse(authorizationGrantRequest); } diff --git a/config/src/test/resources/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests-providers.xml b/config/src/test/resources/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests-providers.xml index 5241e5edb79..0f167f5ed1a 100644 --- a/config/src/test/resources/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests-providers.xml +++ b/config/src/test/resources/org/springframework/security/config/http/OAuth2AuthorizedClientManagerRegistrarTests-providers.xml @@ -42,21 +42,21 @@ factory-method="authorizedClientManagerConsumer"/> + factory-method="authorizationCode"/> + factory-method="refreshToken"/> + factory-method="clientCredentials"/> + factory-method="password"/> + factory-method="jwtBearer"/> + factory-method="tokenExchange"/> \ No newline at end of file diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java new file mode 100644 index 00000000000..ceffb7ded11 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java @@ -0,0 +1,320 @@ +/* + * Copyright 2002-2024 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.lang.reflect.Array; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; + +import org.springframework.aop.Advisor; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; +import org.springframework.security.authorization.method.PostFilterAuthorizationMethodInterceptor; +import org.springframework.security.authorization.method.PreFilterAuthorizationMethodInterceptor; +import org.springframework.util.ClassUtils; + +/** + * A proxy factory for applying authorization advice to an arbitrary object. + * + *

+ * For example, consider a non-Spring-managed object {@code Foo}:

+ *     class Foo {
+ *         @PreAuthorize("hasAuthority('bar:read')")
+ *         String bar() { ... }
+ *     }
+ * 
+ * + * Use {@link AuthorizationAdvisorProxyFactory} to wrap the instance in Spring Security's + * {@link org.springframework.security.access.prepost.PreAuthorize} method interceptor + * like so: + * + *
+ *     AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor.preAuthorize();
+ *     AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize);
+ *     Foo foo = new Foo();
+ *     foo.bar(); // passes
+ *     Foo securedFoo = proxyFactory.proxy(foo);
+ *     securedFoo.bar(); // access denied!
+ * 
+ * + * @author Josh Cummings + * @since 6.3 + */ +public final class AuthorizationAdvisorProxyFactory implements AuthorizationProxyFactory { + + private List advisors = new ArrayList<>(); + + public AuthorizationAdvisorProxyFactory() { + List advisors = new ArrayList<>(); + advisors.add(AuthorizationManagerBeforeMethodInterceptor.preAuthorize()); + advisors.add(AuthorizationManagerAfterMethodInterceptor.postAuthorize()); + advisors.add(new PreFilterAuthorizationMethodInterceptor()); + advisors.add(new PostFilterAuthorizationMethodInterceptor()); + setAdvisors(advisors); + } + + /** + * Proxy an object to enforce authorization advice. + * + *

+ * Proxies any instance of a non-final class or a class that implements more than one + * interface. + * + *

+ * If {@code target} is an {@link Iterator}, {@link Collection}, {@link Array}, + * {@link Map}, {@link Stream}, or {@link Optional}, then the element or value type is + * proxied. + * + *

+ * If {@code target} is a {@link Class}, then {@link ProxyFactory#getProxyClass} is + * invoked instead. + * @param target the instance to proxy + * @return the proxied instance + */ + @Override + public Object proxy(Object target) { + if (target == null) { + return null; + } + if (target instanceof Class targetClass) { + return proxyClass(targetClass); + } + if (target instanceof Iterator iterator) { + return proxyIterator(iterator); + } + if (target instanceof Queue queue) { + return proxyQueue(queue); + } + if (target instanceof List list) { + return proxyList(list); + } + if (target instanceof SortedSet set) { + return proxySortedSet(set); + } + if (target instanceof Set set) { + return proxySet(set); + } + if (target.getClass().isArray()) { + return proxyArray((Object[]) target); + } + if (target instanceof SortedMap map) { + return proxySortedMap(map); + } + if (target instanceof Iterable iterable) { + return proxyIterable(iterable); + } + if (target instanceof Map map) { + return proxyMap(map); + } + if (target instanceof Stream stream) { + return proxyStream(stream); + } + if (target instanceof Optional optional) { + return proxyOptional(optional); + } + ProxyFactory factory = new ProxyFactory(target); + for (Advisor advisor : this.advisors) { + factory.addAdvisors(advisor); + } + factory.setProxyTargetClass(!Modifier.isFinal(target.getClass().getModifiers())); + return factory.getProxy(); + } + + /** + * Add advisors that should be included to each proxy created. + * + *

+ * All advisors are re-sorted by their advisor order. + * @param advisors the advisors to add + */ + public void setAdvisors(AuthorizationAdvisor... advisors) { + this.advisors = new ArrayList<>(List.of(advisors)); + AnnotationAwareOrderComparator.sort(this.advisors); + } + + /** + * Add advisors that should be included to each proxy created. + * + *

+ * All advisors are re-sorted by their advisor order. + * @param advisors the advisors to add + */ + public void setAdvisors(Collection advisors) { + this.advisors = new ArrayList<>(advisors); + AnnotationAwareOrderComparator.sort(this.advisors); + } + + @SuppressWarnings("unchecked") + private T proxyCast(T target) { + return (T) proxy(target); + } + + private Class proxyClass(Class targetClass) { + ProxyFactory factory = new ProxyFactory(); + factory.setTargetClass(targetClass); + factory.setInterfaces(ClassUtils.getAllInterfacesForClass(targetClass)); + factory.setProxyTargetClass(!Modifier.isFinal(targetClass.getModifiers())); + for (Advisor advisor : this.advisors) { + factory.addAdvisors(advisor); + } + return factory.getProxyClass(getClass().getClassLoader()); + } + + private Iterable proxyIterable(Iterable iterable) { + return () -> proxyIterator(iterable.iterator()); + } + + private Iterator proxyIterator(Iterator iterator) { + return new Iterator<>() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public T next() { + return proxyCast(iterator.next()); + } + }; + } + + private SortedSet proxySortedSet(SortedSet set) { + SortedSet proxies = new TreeSet<>(set.comparator()); + for (T toProxy : set) { + proxies.add(proxyCast(toProxy)); + } + try { + set.clear(); + set.addAll(proxies); + return proxies; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableSortedSet(proxies); + } + } + + private Set proxySet(Set set) { + Set proxies = new LinkedHashSet<>(set.size()); + for (T toProxy : set) { + proxies.add(proxyCast(toProxy)); + } + try { + set.clear(); + set.addAll(proxies); + return proxies; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableSet(proxies); + } + } + + private Queue proxyQueue(Queue queue) { + Queue proxies = new LinkedList<>(); + for (T toProxy : queue) { + proxies.add(proxyCast(toProxy)); + } + queue.clear(); + queue.addAll(proxies); + return proxies; + } + + private List proxyList(List list) { + List proxies = new ArrayList<>(list.size()); + for (T toProxy : list) { + proxies.add(proxyCast(toProxy)); + } + try { + list.clear(); + list.addAll(proxies); + return proxies; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableList(proxies); + } + } + + private Object[] proxyArray(Object[] objects) { + List retain = new ArrayList<>(objects.length); + for (Object object : objects) { + retain.add(proxy(object)); + } + Object[] proxies = (Object[]) Array.newInstance(objects.getClass().getComponentType(), retain.size()); + for (int i = 0; i < retain.size(); i++) { + proxies[i] = retain.get(i); + } + return proxies; + } + + private SortedMap proxySortedMap(SortedMap entries) { + SortedMap proxies = new TreeMap<>(entries.comparator()); + for (Map.Entry entry : entries.entrySet()) { + proxies.put(entry.getKey(), proxyCast(entry.getValue())); + } + try { + entries.clear(); + entries.putAll(proxies); + return entries; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableSortedMap(proxies); + } + } + + private Map proxyMap(Map entries) { + Map proxies = new LinkedHashMap<>(entries.size()); + for (Map.Entry entry : entries.entrySet()) { + proxies.put(entry.getKey(), proxyCast(entry.getValue())); + } + try { + entries.clear(); + entries.putAll(proxies); + return entries; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableMap(proxies); + } + } + + private Stream proxyStream(Stream stream) { + return stream.map(this::proxy).onClose(stream::close); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private Optional proxyOptional(Optional optional) { + return optional.map(this::proxy); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationProxyFactory.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationProxyFactory.java new file mode 100644 index 00000000000..0aa4069cb88 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationProxyFactory.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 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; + +/** + * A factory for wrapping arbitrary objects in authorization-related advice + * + * @author Josh Cummings + * @since 6.3 + * @see AuthorizationAdvisorProxyFactory + */ +public interface AuthorizationProxyFactory { + + /** + * Wrap the given {@code object} in authorization-related advice. + * + *

+ * Please check the implementation for which kinds of objects it supports. + * @param object the object to proxy + * @return the proxied object + * @throws org.springframework.aop.framework.AopConfigException if a proxy cannot be + * created + */ + Object proxy(Object object); + +} diff --git a/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationAdvisorProxyFactory.java b/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationAdvisorProxyFactory.java new file mode 100644 index 00000000000..5720082862d --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationAdvisorProxyFactory.java @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2024 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.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.authorization.method.AuthorizationManagerAfterReactiveMethodInterceptor; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeReactiveMethodInterceptor; +import org.springframework.security.authorization.method.PostFilterAuthorizationReactiveMethodInterceptor; +import org.springframework.security.authorization.method.PreFilterAuthorizationReactiveMethodInterceptor; + +/** + * A proxy factory for applying authorization advice to an arbitrary object. + * + *

+ * For example, consider a non-Spring-managed object {@code Foo}:

+ *     class Foo {
+ *         @PreAuthorize("hasAuthority('bar:read')")
+ *         String bar() { ... }
+ *     }
+ * 
+ * + * Use {@link ReactiveAuthorizationAdvisorProxyFactory} to wrap the instance in Spring + * Security's {@link org.springframework.security.access.prepost.PreAuthorize} method + * interceptor like so: + * + *
+ *     AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor.preAuthorize();
+ *     AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize);
+ *     Foo foo = new Foo();
+ *     foo.bar(); // passes
+ *     Foo securedFoo = proxyFactory.proxy(foo);
+ *     securedFoo.bar(); // access denied!
+ * 
+ * + * @author Josh Cummings + * @since 6.3 + */ +public final class ReactiveAuthorizationAdvisorProxyFactory implements AuthorizationProxyFactory { + + private final AuthorizationAdvisorProxyFactory defaults = new AuthorizationAdvisorProxyFactory(); + + public ReactiveAuthorizationAdvisorProxyFactory() { + List advisors = new ArrayList<>(); + advisors.add(AuthorizationManagerBeforeReactiveMethodInterceptor.preAuthorize()); + advisors.add(AuthorizationManagerAfterReactiveMethodInterceptor.postAuthorize()); + advisors.add(new PreFilterAuthorizationReactiveMethodInterceptor()); + advisors.add(new PostFilterAuthorizationReactiveMethodInterceptor()); + this.defaults.setAdvisors(advisors); + } + + /** + * Proxy an object to enforce authorization advice. + * + *

+ * Proxies any instance of a non-final class or a class that implements more than one + * interface. + * + *

+ * If {@code target} is an {@link Iterator}, {@link Collection}, {@link Array}, + * {@link Map}, {@link Stream}, or {@link Optional}, then the element or value type is + * proxied. + * + *

+ * If {@code target} is a {@link Class}, then {@link ProxyFactory#getProxyClass} is + * invoked instead. + * @param target the instance to proxy + * @return the proxied instance + */ + @Override + public Object proxy(Object target) { + if (target instanceof Mono mono) { + return proxyMono(mono); + } + if (target instanceof Flux flux) { + return proxyFlux(flux); + } + return this.defaults.proxy(target); + } + + /** + * Add advisors that should be included to each proxy created. + * + *

+ * All advisors are re-sorted by their advisor order. + * @param advisors the advisors to add + */ + public void setAdvisors(AuthorizationAdvisor... advisors) { + this.defaults.setAdvisors(advisors); + } + + /** + * Add advisors that should be included to each proxy created. + * + *

+ * All advisors are re-sorted by their advisor order. + * @param advisors the advisors to add + */ + public void setAdvisors(Collection advisors) { + this.defaults.setAdvisors(advisors); + } + + private Mono proxyMono(Mono mono) { + return mono.map(this::proxy); + } + + private Flux proxyFlux(Flux flux) { + return flux.map(this::proxy); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisor.java new file mode 100644 index 00000000000..deca6b04406 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisor.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 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.method; + +import org.aopalliance.intercept.MethodInterceptor; + +import org.springframework.aop.PointcutAdvisor; +import org.springframework.aop.framework.AopInfrastructureBean; +import org.springframework.core.Ordered; + +/** + * An interface that indicates method security advice + * + * @author Josh Cummings + * @since 6.3 + * @see AuthorizationManagerBeforeMethodInterceptor + * @see AuthorizationManagerAfterMethodInterceptor + * @see PreFilterAuthorizationMethodInterceptor + * @see PostFilterAuthorizationMethodInterceptor + */ +public interface AuthorizationAdvisor extends Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java index 2971589e336..4d490515f08 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java @@ -25,9 +25,6 @@ import org.apache.commons.logging.LogFactory; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.core.log.LogMessage; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PostAuthorize; @@ -48,8 +45,7 @@ * @author Josh Cummings * @since 5.6 */ -public final class AuthorizationManagerAfterMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class AuthorizationManagerAfterMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java index 550b8fbdef3..ea3d0236d8c 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java @@ -28,11 +28,8 @@ import reactor.core.publisher.Mono; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; -import org.springframework.core.Ordered; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.security.access.prepost.PostAuthorize; @@ -48,8 +45,7 @@ * @author Evgeniy Cheban * @since 5.8 */ -public final class AuthorizationManagerAfterReactiveMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class AuthorizationManagerAfterReactiveMethodInterceptor implements AuthorizationAdvisor { private static final String COROUTINES_FLOW_CLASS_NAME = "kotlinx.coroutines.flow.Flow"; diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java index 0f38826d13b..4d84a55616d 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java @@ -28,9 +28,6 @@ import org.apache.commons.logging.LogFactory; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.core.log.LogMessage; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.annotation.Secured; @@ -52,8 +49,7 @@ * @author Josh Cummings * @since 5.6 */ -public final class AuthorizationManagerBeforeMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class AuthorizationManagerBeforeMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java index f3d1cce8bfa..25e43e15883 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java @@ -27,11 +27,8 @@ import reactor.core.publisher.Mono; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; -import org.springframework.core.Ordered; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.security.access.prepost.PreAuthorize; @@ -48,8 +45,7 @@ * @author Josh Cummings * @since 5.8 */ -public final class AuthorizationManagerBeforeReactiveMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class AuthorizationManagerBeforeReactiveMethodInterceptor implements AuthorizationAdvisor { private static final String COROUTINES_FLOW_CLASS_NAME = "kotlinx.coroutines.flow.Flow"; diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java index aadc75c0036..aa96de670da 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java @@ -23,9 +23,6 @@ import org.aopalliance.intercept.MethodInvocation; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.expression.EvaluationContext; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PostFilter; @@ -43,8 +40,7 @@ * @author Josh Cummings * @since 5.6 */ -public final class PostFilterAuthorizationMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class PostFilterAuthorizationMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java index 19dbbb7e0be..072bb5b75d5 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java @@ -26,9 +26,6 @@ import reactor.core.publisher.Mono; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.expression.EvaluationContext; @@ -46,8 +43,7 @@ * @author Evgeniy Cheban * @since 5.8 */ -public final class PostFilterAuthorizationReactiveMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class PostFilterAuthorizationReactiveMethodInterceptor implements AuthorizationAdvisor { private final PostFilterExpressionAttributeRegistry registry = new PostFilterExpressionAttributeRegistry(); diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java index 39ae4e257ca..a00e22f2534 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java @@ -23,9 +23,6 @@ import org.aopalliance.intercept.MethodInvocation; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.expression.EvaluationContext; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PreFilter; @@ -44,8 +41,7 @@ * @author Josh Cummings * @since 5.6 */ -public final class PreFilterAuthorizationMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class PreFilterAuthorizationMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java index 18f7d61123b..19775eb126e 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java @@ -26,10 +26,7 @@ import reactor.core.publisher.Mono; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.aop.support.AopUtils; -import org.springframework.core.Ordered; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; @@ -50,8 +47,7 @@ * @author Evgeniy Cheban * @since 5.8 */ -public final class PreFilterAuthorizationReactiveMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class PreFilterAuthorizationReactiveMethodInterceptor implements AuthorizationAdvisor { private final PreFilterExpressionAttributeRegistry registry = new PreFilterExpressionAttributeRegistry(); diff --git a/core/src/test/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactoryTests.java b/core/src/test/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactoryTests.java new file mode 100644 index 00000000000..9ca1ce9b4df --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactoryTests.java @@ -0,0 +1,390 @@ +/* + * Copyright 2002-2024 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.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import org.springframework.aop.Pointcut; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class AuthorizationAdvisorProxyFactoryTests { + + private final Authentication user = TestAuthentication.authenticatedUser(); + + private final Authentication admin = TestAuthentication.authenticatedAdmin(); + + private final Flight flight = new Flight(); + + private final User alan = new User("alan", "alan", "turing"); + + @Test + public void proxyWhenPreAuthorizeThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Flight flight = new Flight(); + assertThat(flight.getAltitude()).isEqualTo(35000d); + Flight secured = proxy(factory, flight); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::getAltitude); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeOnInterfaceThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + assertThat(this.alan.getFirstName()).isEqualTo("alan"); + User secured = proxy(factory, this.alan); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::getFirstName); + SecurityContextHolder.getContext().setAuthentication(authenticated("alan")); + assertThat(secured.getFirstName()).isEqualTo("alan"); + SecurityContextHolder.getContext().setAuthentication(this.admin); + assertThat(secured.getFirstName()).isEqualTo("alan"); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeOnRecordThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + HasSecret repo = new Repository("secret"); + assertThat(repo.secret()).isEqualTo("secret"); + HasSecret secured = proxy(factory, repo); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::secret); + SecurityContextHolder.getContext().setAuthentication(this.user); + assertThat(repo.secret()).isEqualTo("secret"); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableListThenReturnsSecuredImmutableList() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + List flights = List.of(this.flight); + List secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableSetThenReturnsSecuredImmutableSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Set flights = Set.of(this.flight); + Set secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenQueueThenReturnsSecuredQueue() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Queue flights = new LinkedList<>(List.of(this.flight)); + Queue secured = proxy(factory, flights); + assertThat(flights.size()).isEqualTo(secured.size()); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableSortedSetThenReturnsSecuredImmutableSortedSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + SortedSet users = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of(this.alan))); + SortedSet secured = proxy(factory, users); + secured + .forEach((user) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getFirstName)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableSortedMapThenReturnsSecuredImmutableSortedMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + SortedMap users = Collections + .unmodifiableSortedMap(new TreeMap<>(Map.of(this.alan.getId(), this.alan))); + SortedMap secured = proxy(factory, users); + secured.forEach( + (id, user) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getFirstName)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableMapThenReturnsSecuredImmutableMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Map users = Map.of(this.alan.getId(), this.alan); + Map secured = proxy(factory, users); + secured.forEach( + (id, user) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getFirstName)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableListThenReturnsSecuredMutableList() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + List flights = new ArrayList<>(List.of(this.flight)); + List secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableSetThenReturnsSecuredMutableSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Set flights = new HashSet<>(Set.of(this.flight)); + Set secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableSortedSetThenReturnsSecuredMutableSortedSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + SortedSet users = new TreeSet<>(Set.of(this.alan)); + SortedSet secured = proxy(factory, users); + secured.forEach((u) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(u::getFirstName)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableSortedMapThenReturnsSecuredMutableSortedMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + SortedMap users = new TreeMap<>(Map.of(this.alan.getId(), this.alan)); + SortedMap secured = proxy(factory, users); + secured.forEach((id, u) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(u::getFirstName)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableMapThenReturnsSecuredMutableMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Map users = new HashMap<>(Map.of(this.alan.getId(), this.alan)); + Map secured = proxy(factory, users); + secured.forEach((id, u) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(u::getFirstName)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForOptionalThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Optional flights = Optional.of(this.flight); + assertThat(flights.get().getAltitude()).isEqualTo(35000d); + Optional secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.ifPresent(Flight::getAltitude)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForStreamThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Stream flights = Stream.of(this.flight); + Stream secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.forEach(Flight::getAltitude)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForArrayThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Flight[] flights = { this.flight }; + Flight[] secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured[0]::getAltitude); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForIteratorThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Iterator flights = List.of(this.flight).iterator(); + Iterator secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.next().getAltitude()); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForIterableThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Iterable users = new UserRepository(); + Iterable secured = proxy(factory, users); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.forEach(User::getFirstName)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForClassThenHonors() { + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + Class clazz = proxy(factory, Flight.class); + assertThat(clazz.getSimpleName()).contains("SpringCGLIB$$0"); + Flight secured = proxy(factory, this.flight); + assertThat(secured.getClass()).isSameAs(clazz); + SecurityContextHolder.getContext().setAuthentication(this.user); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::getAltitude); + SecurityContextHolder.clearContext(); + } + + @Test + public void setAdvisorsWhenProxyThenVisits() { + AuthorizationAdvisor advisor = mock(AuthorizationAdvisor.class); + given(advisor.getAdvice()).willReturn(advisor); + given(advisor.getPointcut()).willReturn(Pointcut.TRUE); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + factory.setAdvisors(advisor); + Flight flight = proxy(factory, this.flight); + flight.getAltitude(); + verify(advisor, atLeastOnce()).getPointcut(); + } + + private Authentication authenticated(String user, String... authorities) { + return TestAuthentication.authenticated(TestAuthentication.withUsername(user).authorities(authorities).build()); + } + + private T proxy(AuthorizationProxyFactory factory, Object target) { + return (T) factory.proxy(target); + } + + static class Flight { + + @PreAuthorize("hasRole('PILOT')") + Double getAltitude() { + return 35000d; + } + + } + + interface Identifiable { + + @PreAuthorize("authentication.name == this.id || hasRole('ADMIN')") + String getFirstName(); + + @PreAuthorize("authentication.name == this.id || hasRole('ADMIN')") + String getLastName(); + + } + + public static class User implements Identifiable, Comparable { + + private final String id; + + private final String firstName; + + private final String lastName; + + User(String id, String firstName, String lastName) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + } + + public String getId() { + return this.id; + } + + @Override + public String getFirstName() { + return this.firstName; + } + + @Override + public String getLastName() { + return this.lastName; + } + + @Override + public int compareTo(@NotNull User that) { + return this.id.compareTo(that.getId()); + } + + } + + static class UserRepository implements Iterable { + + List users = List.of(new User("1", "first", "last")); + + @NotNull + @Override + public Iterator iterator() { + return this.users.iterator(); + } + + } + + interface HasSecret { + + String secret(); + + } + + record Repository(@PreAuthorize("hasRole('ADMIN')") String secret) implements HasSecret { + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/ReactiveAuthorizationAdvisorProxyFactoryTests.java b/core/src/test/java/org/springframework/security/authorization/ReactiveAuthorizationAdvisorProxyFactoryTests.java new file mode 100644 index 00000000000..1dc8afccdce --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/ReactiveAuthorizationAdvisorProxyFactoryTests.java @@ -0,0 +1,226 @@ +/* + * Copyright 2002-2024 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.Iterator; +import java.util.List; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.aop.Pointcut; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class ReactiveAuthorizationAdvisorProxyFactoryTests { + + private final Authentication user = TestAuthentication.authenticatedUser(); + + private final Authentication admin = TestAuthentication.authenticatedAdmin(); + + private final Flight flight = new Flight(); + + private final User alan = new User("alan", "alan", "turing"); + + @Test + public void proxyWhenPreAuthorizeThenHonors() { + ReactiveAuthorizationAdvisorProxyFactory factory = new ReactiveAuthorizationAdvisorProxyFactory(); + Flight flight = new Flight(); + StepVerifier + .create(flight.getAltitude().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .expectNext(35000d) + .verifyComplete(); + Flight secured = proxy(factory, flight); + StepVerifier + .create(secured.getAltitude().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .verifyError(AccessDeniedException.class); + } + + @Test + public void proxyWhenPreAuthorizeOnInterfaceThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + ReactiveAuthorizationAdvisorProxyFactory factory = new ReactiveAuthorizationAdvisorProxyFactory(); + StepVerifier + .create(this.alan.getFirstName().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .expectNext("alan") + .verifyComplete(); + User secured = proxy(factory, this.alan); + StepVerifier + .create(secured.getFirstName().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .verifyError(AccessDeniedException.class); + StepVerifier + .create(secured.getFirstName() + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authenticated("alan")))) + .expectNext("alan") + .verifyComplete(); + StepVerifier + .create(secured.getFirstName().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.admin))) + .expectNext("alan") + .verifyComplete(); + } + + @Test + public void proxyWhenPreAuthorizeOnRecordThenHonors() { + ReactiveAuthorizationAdvisorProxyFactory factory = new ReactiveAuthorizationAdvisorProxyFactory(); + HasSecret repo = new Repository(Mono.just("secret")); + StepVerifier.create(repo.secret().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .expectNext("secret") + .verifyComplete(); + HasSecret secured = proxy(factory, repo); + StepVerifier.create(secured.secret().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .verifyError(AccessDeniedException.class); + StepVerifier.create(secured.secret().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.admin))) + .expectNext("secret") + .verifyComplete(); + } + + @Test + public void proxyWhenPreAuthorizeOnFluxThenHonors() { + ReactiveAuthorizationAdvisorProxyFactory factory = new ReactiveAuthorizationAdvisorProxyFactory(); + Flux flights = Flux.just(this.flight); + Flux secured = proxy(factory, flights); + StepVerifier + .create(secured.flatMap(Flight::getAltitude) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .verifyError(AccessDeniedException.class); + } + + @Test + public void proxyWhenPreAuthorizeForClassThenHonors() { + ReactiveAuthorizationAdvisorProxyFactory factory = new ReactiveAuthorizationAdvisorProxyFactory(); + Class clazz = proxy(factory, Flight.class); + assertThat(clazz.getSimpleName()).contains("SpringCGLIB$$0"); + Flight secured = proxy(factory, this.flight); + StepVerifier + .create(secured.getAltitude().contextWrite(ReactiveSecurityContextHolder.withAuthentication(this.user))) + .verifyError(AccessDeniedException.class); + } + + @Test + public void setAdvisorsWhenProxyThenVisits() { + AuthorizationAdvisor advisor = mock(AuthorizationAdvisor.class); + given(advisor.getAdvice()).willReturn(advisor); + given(advisor.getPointcut()).willReturn(Pointcut.TRUE); + ReactiveAuthorizationAdvisorProxyFactory factory = new ReactiveAuthorizationAdvisorProxyFactory(); + factory.setAdvisors(advisor); + Flight flight = proxy(factory, this.flight); + flight.getAltitude(); + verify(advisor, atLeastOnce()).getPointcut(); + } + + private Authentication authenticated(String user, String... authorities) { + return TestAuthentication.authenticated(TestAuthentication.withUsername(user).authorities(authorities).build()); + } + + private T proxy(AuthorizationProxyFactory factory, Object target) { + return (T) factory.proxy(target); + } + + static class Flight { + + @PreAuthorize("hasRole('PILOT')") + Mono getAltitude() { + return Mono.just(35000d); + } + + } + + interface Identifiable { + + @PreAuthorize("authentication.name == this.id || hasRole('ADMIN')") + Mono getFirstName(); + + @PreAuthorize("authentication.name == this.id || hasRole('ADMIN')") + Mono getLastName(); + + } + + public static class User implements Identifiable, Comparable { + + private final String id; + + private final String firstName; + + private final String lastName; + + User(String id, String firstName, String lastName) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + } + + public String getId() { + return this.id; + } + + @Override + public Mono getFirstName() { + return Mono.just(this.firstName); + } + + @Override + public Mono getLastName() { + return Mono.just(this.lastName); + } + + @Override + public int compareTo(@NotNull User that) { + return this.id.compareTo(that.getId()); + } + + } + + static class UserRepository implements Iterable { + + List users = List.of(new User("1", "first", "last")); + + Flux findAll() { + return Flux.fromIterable(this.users); + } + + @NotNull + @Override + public Iterator iterator() { + return this.users.iterator(); + } + + } + + interface HasSecret { + + Mono secret(); + + } + + record Repository(@PreAuthorize("hasRole('ADMIN')") Mono secret) implements HasSecret { + } + +} diff --git a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc index ccd328e7d5b..62d956ce2b6 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc @@ -1702,6 +1702,397 @@ This works on both classes and interfaces. This does not work for interfaces, since they do not have debug information about the parameter names. For interfaces, either annotations or the `-parameters` approach must be used. +[[authorize-object]] +== Authorizing Arbitrary Objects + +Spring Security also supports wrapping any object that is annotated its method security annotations. + +To achieve this, you can autowire the provided `AuthorizationProxyFactory` instance, which is based on which method security interceptors you have configured. +If you are using `@EnableMethodSecurity`, then this means that it will by default have the interceptors for `@PreAuthorize`, `@PostAuthorize`, `@PreFilter`, and `@PostFilter`. + +For example, consider the following `User` class: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +public class User { + private String name; + private String email; + + public User(String name, String email) { + this.name = name; + this.email = email; + } + + public String getName() { + return this.name; + } + + @PreAuthorize("hasAuthority('user:read')") + public String getEmail() { + return this.email; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +class User (val name:String, @get:PreAuthorize("hasAuthority('user:read')") val email:String) +---- +====== + +You can proxy an instance of user in the following way: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Autowired +AuthorizationProxyFactory proxyFactory; + +@Test +void getEmailWhenProxiedThenAuthorizes() { + User user = new User("name", "email"); + assertThat(user.getEmail()).isNotNull(); + User securedUser = proxyFactory.proxy(user); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(securedUser::getEmail); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Autowired +var proxyFactory:AuthorizationProxyFactory? = null + +@Test +fun getEmailWhenProxiedThenAuthorizes() { + val user: User = User("name", "email") + assertThat(user.getEmail()).isNotNull() + val securedUser: User = proxyFactory.proxy(user) + assertThatExceptionOfType(AccessDeniedException::class.java).isThrownBy(securedUser::getEmail) +} +---- +====== + +=== Manual Construction + +You can also define your own instance if you need something different from the Spring Security default. + +For example, if you define an `AuthorizationProxyFactory` instance like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +import static org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize; + +// ... + +AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize()); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize + +// ... + +val proxyFactory: AuthorizationProxyFactory = AuthorizationProxyFactory(preAuthorize()) +---- +====== + +Then you can wrap any instance of `User` as follows: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Test +void getEmailWhenProxiedThenAuthorizes() { + AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize()); + User user = new User("name", "email"); + assertThat(user.getEmail()).isNotNull(); + User securedUser = proxyFactory.proxy(user); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(securedUser::getEmail); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Test +fun getEmailWhenProxiedThenAuthorizes() { + val proxyFactory: AuthorizationProxyFactory = AuthorizationProxyFactory(preAuthorize()) + val user: User = User("name", "email") + assertThat(user.getEmail()).isNotNull() + val securedUser: User = proxyFactory.proxy(user) + assertThatExceptionOfType(AccessDeniedException::class.java).isThrownBy(securedUser::getEmail) +} +---- +====== + +[NOTE] +==== +This feature does not yet support Spring AOT +==== + +=== Proxying Collections + +`AuthorizationProxyFactory` supports Java collections, streams, arrays, optionals, and iterators by proxying the element type and maps by proxying the value type. + +This means that when proxying a `List` of objects, the following also works: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Test +void getEmailWhenProxiedThenAuthorizes() { + AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize()); + List users = List.of(ada, albert, marie); + List securedUsers = proxyFactory.proxy(users); + securedUsers.forEach((securedUser) -> + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(securedUser::getEmail)); +} +---- +====== + +=== Proxying Classes + +In limited circumstances, it may be valuable to proxy a `Class` itself, and `AuthorizationProxyFactory` also supports this. +This is roughly the equivalent of calling `ProxyFactory#getProxyClass` in Spring Framework's support for creating proxies. + +One place where this is handy is when you need to construct the proxy class ahead-of-time, like with Spring AOT. + +=== Support for All Method Security Annotations + +`AuthorizationProxyFactory` supports whichever method security annotations are enabled in your application. +It is based off of whatever `AuthorizationAdvisor` classes are published as a bean. + +Since `@EnableMethodSecurity` publishes `@PreAuthorize`, `@PostAuthorize`, `@PreFilter`, and `@PostFilter` advisors by default, you will typically need to do nothing to activate the ability. + +[NOTE] +==== +SpEL expressions that use `returnObject` or `filterObject` sit behind the proxy and so have full access to the object. +==== + +[#custom_advice] +=== Custom Advice + +If you have security advice that you also want applied, you can publish your own `AuthorizationAdvisor` like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@EnableMethodSecurity +class SecurityConfig { + @Bean + static AuthorizationAdvisor myAuthorizationAdvisor() { + return new AuthorizationAdvisor(); + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@EnableMethodSecurity +internal class SecurityConfig { + @Bean + fun myAuthorizationAdvisor(): AuthorizationAdvisor { + return AuthorizationAdvisor() + } +] +---- +====== + +And Spring Security will add that advisor into the set of advice that `AuthorizationProxyFactory` adds when proxying an object. + +=== Working with Jackson + +One powerful use of this feature is to return a secured value from a controller like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@RestController +public class UserController { + @Autowired + AuthorizationProxyFactory proxyFactory; + + @GetMapping + User currentUser(@AuthenticationPrincipal User user) { + return this.proxyFactory.proxy(user); + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@RestController +class UserController { + @Autowired + var proxyFactory: AuthorizationProxyFactory? = null + + @GetMapping + fun currentUser(@AuthenticationPrincipal user:User?): User { + return proxyFactory.proxy(user) + } +} +---- +====== + +If you are using Jackson, though, this may result in a serialization error like the following: + +[source,bash] +==== +com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle +==== + +This is due to how Jackson works with CGLIB proxies. +To address this, add the following annotation to the top of the `User` class: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@JsonSerialize(as = User.class) +public class User { + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@JsonSerialize(`as` = User::class) +class User +---- +====== + +Finally, you will need to publish a <> to catch the `AccessDeniedException` thrown for each field, which you can do like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +public class AccessDeniedExceptionInterceptor implements AuthorizationAdvisor { + private final AuthorizationAdvisor advisor = AuthorizationManagerBeforeMethodInterceptor.preAuthorize(); + + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + try { + return invocation.proceed(); + } catch (AccessDeniedException ex) { + return null; + } + } + + @Override + public Pointcut getPointcut() { + return this.advisor.getPointcut(); + } + + @Override + public Advice getAdvice() { + return this; + } + + @Override + public int getOrder() { + return this.advisor.getOrder() - 1; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class AccessDeniedExceptionInterceptor: AuthorizationAdvisor { + var advisor: AuthorizationAdvisor = AuthorizationManagerBeforeMethodInterceptor.preAuthorize() + + @Throws(Throwable::class) + fun invoke(invocation: MethodInvocation): Any? { + return try { + invocation.proceed() + } catch (ex:AccessDeniedException) { + null + } + } + + val pointcut: Pointcut + get() = advisor.getPointcut() + + val advice: Advice + get() = this + + val order: Int + get() = advisor.getOrder() - 1 +} +---- +====== + +Then, you'll see a different JSON serialization based on the authorization level of the user. +If they don't have the `user:read` authority, then they'll see: + +[source,json] +---- +{ + "name" : "name", + "email" : null +} +---- + +And if they do have that authority, they'll see: + +[source,json] +---- +{ + "name" : "name", + "email" : "email" +} +---- + +[TIP] +==== +You can also add the Spring Boot property `spring.jackson.default-property-inclusion=non_null` to exclude the null value, if you also don't want to reveal the JSON key to an unauthorized user. +==== + [[migration-enableglobalmethodsecurity]] == Migrating from `@EnableGlobalMethodSecurity` diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 6f1756c0cc0..dc7122d483f 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -8,6 +8,10 @@ Below are the highlights of the release. - https://spring.io/blog/2024/01/19/spring-security-6-3-adds-passive-jdk-serialization-deserialization-for[blog post] - Added Passive JDK Serialization/Deserialization for Seamless Upgrades +== Authorization + +- https://github.com/spring-projects/spring-security/issues/14596[gh-14596] - xref:servlet/authorization/method-security.adoc[docs] - Add Programmatic Proxy Support for Method Security + == Configuration - https://github.com/spring-projects/spring-security/issues/6192[gh-6192] - xref:reactive/authentication/concurrent-sessions-control.adoc[(docs)] - Add Concurrent Sessions Control on WebFlux diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c34064a7a0b..39caea6075b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,13 +11,13 @@ org-bouncycastle = "1.70" org-eclipse-jetty = "11.0.20" org-jetbrains-kotlin = "1.9.23" org-jetbrains-kotlinx = "1.8.0" -org-mockito = "5.5.0" +org-mockito = "5.11.0" org-opensaml = "4.3.0" -org-springframework = "6.1.4" +org-springframework = "6.1.5" [libraries] -ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.4.14" -com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.15.4" +ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.3" +com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.17.0" com-google-inject-guice = "com.google.inject:guice:3.0" com-netflix-nebula-nebula-project-plugin = "com.netflix.nebula:nebula-project-plugin:8.2.0" com-nimbusds-nimbus-jose-jwt = "com.nimbusds:nimbus-jose-jwt:9.37.3" @@ -58,16 +58,16 @@ org-apache-maven-resolver-maven-resolver-connector-basic = { module = "org.apach org-apache-maven-resolver-maven-resolver-impl = { module = "org.apache.maven.resolver:maven-resolver-impl", version.ref = "org-apache-maven-resolver" } org-apache-maven-resolver-maven-resolver-transport-http = { module = "org.apache.maven.resolver:maven-resolver-transport-http", version.ref = "org-apache-maven-resolver" } org-apereo-cas-client-cas-client-core = "org.apereo.cas.client:cas-client-core:4.0.4" -io-freefair-gradle-aspectj-plugin = "io.freefair.gradle:aspectj-plugin:8.4" +io-freefair-gradle-aspectj-plugin = "io.freefair.gradle:aspectj-plugin:8.6" org-aspectj-aspectjrt = { module = "org.aspectj:aspectjrt", version.ref = "org-aspectj" } org-aspectj-aspectjweaver = { module = "org.aspectj:aspectjweaver", version.ref = "org-aspectj" } -org-assertj-assertj-core = "org.assertj:assertj-core:3.24.2" +org-assertj-assertj-core = "org.assertj:assertj-core:3.25.3" org-bouncycastle-bcpkix-jdk15on = { module = "org.bouncycastle:bcpkix-jdk15on", version.ref = "org-bouncycastle" } org-bouncycastle-bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "org-bouncycastle" } org-eclipse-jetty-jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "org-eclipse-jetty" } org-eclipse-jetty-jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "org-eclipse-jetty" } org-hamcrest = "org.hamcrest:hamcrest:2.2" -org-hibernate-orm-hibernate-core = "org.hibernate.orm:hibernate-core:6.3.2.Final" +org-hibernate-orm-hibernate-core = "org.hibernate.orm:hibernate-core:6.4.4.Final" org-hsqldb = "org.hsqldb:hsqldb:2.7.2" org-jetbrains-kotlin-kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "org-jetbrains-kotlin" } org-jetbrains-kotlin-kotlin-gradle-plugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.23" @@ -92,15 +92,15 @@ org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipar com-google-code-gson-gson = "com.google.code.gson:gson:2.10.1" com-thaiopensource-trag = "com.thaiopensource:trang:20091111" net-sourceforge-saxon-saxon = "net.sourceforge.saxon:saxon:9.1.0.8" -org-yaml-snakeyaml = "org.yaml:snakeyaml:1.30" +org-yaml-snakeyaml = "org.yaml:snakeyaml:1.33" org-apache-commons-commons-io = "org.apache.commons:commons-io:1.3.2" -io-github-gradle-nexus-publish-plugin = "io.github.gradle-nexus:publish-plugin:1.1.0" -org-gretty-gretty = "org.gretty:gretty:4.0.3" -com-github-ben-manes-gradle-versions-plugin = "com.github.ben-manes:gradle-versions-plugin:0.38.0" +io-github-gradle-nexus-publish-plugin = "io.github.gradle-nexus:publish-plugin:1.3.0" +org-gretty-gretty = "org.gretty:gretty:4.1.2" +com-github-ben-manes-gradle-versions-plugin = "com.github.ben-manes:gradle-versions-plugin:0.51.0" com-github-spullara-mustache-java-compiler = "com.github.spullara.mustache.java:compiler:0.9.11" org-hidetake-gradle-ssh-plugin = "org.hidetake:gradle-ssh-plugin:2.10.1" org-jfrog-buildinfo-build-info-extractor-gradle = "org.jfrog.buildinfo:build-info-extractor-gradle:4.33.13" -org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" +org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8.0.1969" org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1" [plugins] diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java index d482d7c1968..49a3fa933f3 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java @@ -182,6 +182,18 @@ public final class OAuth2ParameterNames { */ public static final String INTERVAL = "interval"; + /** + * {@code audience} - used in Token Exchange Access Token Request. + * @since 6.3 + */ + public static final String AUDIENCE = "audience"; + + /** + * {@code resource} - used in Token Exchange Access Token Request. + * @since 6.3 + */ + public static final String RESOURCE = "resource"; + /** * {@code requested_token_type} - used in Token Exchange Access Token Request. * @since 6.3 @@ -192,7 +204,7 @@ public final class OAuth2ParameterNames { * {@code issued_token_type} - used in Token Exchange Access Token Response. * @since 6.3 */ - private static final String ISSUED_TOKEN_TYPE = "issued_token_type"; + public static final String ISSUED_TOKEN_TYPE = "issued_token_type"; /** * {@code subject_token} - used in Token Exchange Access Token Request. diff --git a/settings.gradle b/settings.gradle index 3e0093ad53b..6ec23e53d04 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,7 +5,7 @@ pluginManagement { } plugins { - id "com.gradle.enterprise" version "3.12.6" + id "com.gradle.enterprise" version "3.16.2" id "io.spring.ge.conventions" version "0.0.15" } diff --git a/web/src/main/java/org/springframework/security/web/authentication/DelegatingAuthenticationConverter.java b/web/src/main/java/org/springframework/security/web/authentication/DelegatingAuthenticationConverter.java new file mode 100644 index 00000000000..f10e3cffcb3 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/DelegatingAuthenticationConverter.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2024 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.web.authentication; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * A {@link AuthenticationConverter}, that iterates over multiple + * {@link AuthenticationConverter}. The first non-null {@link Authentication} will be used + * as a result. + * + * @author Max Batischev + * @since 6.3 + */ +public final class DelegatingAuthenticationConverter implements AuthenticationConverter { + + private final List delegates; + + public DelegatingAuthenticationConverter(List delegates) { + Assert.notEmpty(delegates, "delegates cannot be null"); + this.delegates = new ArrayList<>(delegates); + } + + public DelegatingAuthenticationConverter(AuthenticationConverter... delegates) { + Assert.notEmpty(delegates, "delegates cannot be null"); + this.delegates = List.of(delegates); + } + + @Override + public Authentication convert(HttpServletRequest request) { + for (AuthenticationConverter delegate : this.delegates) { + Authentication authentication = delegate.convert(request); + if (authentication != null) { + return authentication; + } + } + return null; + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/DelegatingAuthenticationConverterTests.java b/web/src/test/java/org/springframework/security/web/authentication/DelegatingAuthenticationConverterTests.java new file mode 100644 index 00000000000..6de7ef325ae --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/DelegatingAuthenticationConverterTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2024 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.web.authentication; + +import jakarta.servlet.http.HttpServletRequest; +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.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.CodecTestUtils; +import org.springframework.security.web.authentication.www.BasicAuthenticationConverter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link DelegatingAuthenticationConverter}. + * + * @author Max Batischev + */ +@ExtendWith(MockitoExtension.class) +public class DelegatingAuthenticationConverterTests { + + private static final String X_AUTH_TOKEN_HEADER = "X-Auth-Token"; + + private static final String TEST_X_AUTH_TOKEN = "test-x-auth-token"; + + private static final String TEST_CUSTOM_PRINCIPAL = "test_custom_principal"; + + private static final String TEST_CUSTOM_CREDENTIALS = "test_custom_credentials"; + + private static final String TEST_BASIC_CREDENTIALS = "username:password"; + + private static final String INVALID_BASIC_CREDENTIALS = "invalid_credentials"; + + private DelegatingAuthenticationConverter converter; + + @Mock + private AuthenticationDetailsSource authenticationDetailsSource; + + @Test + public void requestWhenBasicAuthorizationHeaderIsPresentThenAuthenticates() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, "Basic " + CodecTestUtils.encodeBase64(TEST_BASIC_CREDENTIALS)); + this.converter = new DelegatingAuthenticationConverter( + new BasicAuthenticationConverter(this.authenticationDetailsSource), + new TestNullableAuthenticationConverter()); + + Authentication authentication = this.converter.convert(request); + + assertThat(authentication).isNotNull(); + assertThat(authentication.getName()).isEqualTo("username"); + } + + @Test + public void requestWhenXAuthHeaderIsPresentThenAuthenticates() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(X_AUTH_TOKEN_HEADER, TEST_X_AUTH_TOKEN); + this.converter = new DelegatingAuthenticationConverter(new TestAuthenticationConverter(), + new TestNullableAuthenticationConverter()); + + Authentication authentication = this.converter.convert(request); + + assertThat(authentication).isNotNull(); + assertThat(authentication.getName()).isEqualTo(TEST_CUSTOM_PRINCIPAL); + } + + @Test + public void requestWhenXAuthHeaderIsPresentThenDoesntAuthenticate() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(X_AUTH_TOKEN_HEADER, TEST_X_AUTH_TOKEN); + this.converter = new DelegatingAuthenticationConverter(new TestNullableAuthenticationConverter()); + + Authentication authentication = this.converter.convert(request); + + assertThat(authentication).isNull(); + } + + @Test + public void requestWhenInvalidBasicAuthorizationTokenThenError() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, "Basic " + CodecTestUtils.encodeBase64(INVALID_BASIC_CREDENTIALS)); + this.converter = new DelegatingAuthenticationConverter( + new BasicAuthenticationConverter(this.authenticationDetailsSource), + new TestNullableAuthenticationConverter()); + + assertThatExceptionOfType(BadCredentialsException.class).isThrownBy(() -> this.converter.convert(request)); + } + + private static class TestAuthenticationConverter implements AuthenticationConverter { + + @Override + public Authentication convert(HttpServletRequest request) { + String header = request.getHeader(X_AUTH_TOKEN_HEADER); + if (header != null) { + return new TestingAuthenticationToken(TEST_CUSTOM_PRINCIPAL, TEST_CUSTOM_CREDENTIALS); + } + else { + return null; + } + } + + } + + private static class TestNullableAuthenticationConverter implements AuthenticationConverter { + + @Override + public Authentication convert(HttpServletRequest request) { + return null; + } + + } + +}