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 928ed485484..f8f5c8f1723 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 @@ -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. 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 7fea76850df..1b3d4c1bfae 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 @@ -98,6 +98,7 @@ static MethodInterceptor preAuthorizeAuthorizationMethodInterceptor( ObjectProvider registryProvider, ObjectProvider roleHierarchyProvider, PrePostMethodSecurityConfiguration configuration, ApplicationContext context) { PreAuthorizeAuthorizationManager manager = new PreAuthorizeAuthorizationManager(); + manager.setApplicationContext(context); AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor .preAuthorize(manager(manager, registryProvider)); preAuthorize.setOrder(preAuthorize.getOrder() + configuration.interceptorOrderOffset); @@ -121,6 +122,7 @@ static MethodInterceptor postAuthorizeAuthorizationMethodInterceptor( ObjectProvider registryProvider, ObjectProvider roleHierarchyProvider, PrePostMethodSecurityConfiguration configuration, ApplicationContext context) { PostAuthorizeAuthorizationManager manager = new PostAuthorizeAuthorizationManager(); + manager.setApplicationContext(context); AuthorizationManagerAfterMethodInterceptor postAuthorize = AuthorizationManagerAfterMethodInterceptor .postAuthorize(manager(manager, registryProvider)); postAuthorize.setOrder(postAuthorize.getOrder() + configuration.interceptorOrderOffset); 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 2bfa745f676..8e777e8bc4c 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 @@ -31,6 +31,7 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; @@ -74,9 +75,10 @@ static MethodInterceptor preFilterAuthorizationMethodInterceptor(MethodSecurityE static MethodInterceptor preAuthorizeAuthorizationMethodInterceptor( MethodSecurityExpressionHandler expressionHandler, ObjectProvider defaultsObjectProvider, - ObjectProvider registryProvider) { + ObjectProvider registryProvider, ApplicationContext context) { PreAuthorizeReactiveAuthorizationManager manager = new PreAuthorizeReactiveAuthorizationManager( expressionHandler); + manager.setApplicationContext(context); ReactiveAuthorizationManager authorizationManager = manager(manager, registryProvider); AuthorizationAdvisor interceptor = AuthorizationManagerBeforeReactiveMethodInterceptor .preAuthorize(authorizationManager); @@ -99,9 +101,10 @@ static MethodInterceptor postFilterAuthorizationMethodInterceptor(MethodSecurity static MethodInterceptor postAuthorizeAuthorizationMethodInterceptor( MethodSecurityExpressionHandler expressionHandler, ObjectProvider defaultsObjectProvider, - ObjectProvider registryProvider) { + ObjectProvider registryProvider, ApplicationContext context) { PostAuthorizeReactiveAuthorizationManager manager = new PostAuthorizeReactiveAuthorizationManager( expressionHandler); + manager.setApplicationContext(context); ReactiveAuthorizationManager authorizationManager = manager(manager, registryProvider); AuthorizationAdvisor interceptor = AuthorizationManagerAfterReactiveMethodInterceptor .postAuthorize(authorizationManager); diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityService.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityService.java index 00451610ed3..c3162debb3c 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityService.java +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityService.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. @@ -16,23 +16,42 @@ package org.springframework.security.config.annotation.method.configuration; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.util.List; import jakarta.annotation.security.DenyAll; import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PostFilter; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreFilter; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.method.AuthorizeReturnObject; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor; +import org.springframework.security.authorization.method.MethodInvocationResult; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.parameters.P; +import org.springframework.util.StringUtils; /** * @author Rob Winch */ +@MethodSecurityService.Mask("classmask") public interface MethodSecurityService { @PreAuthorize("denyAll") @@ -108,4 +127,196 @@ public interface MethodSecurityService { @RequireAdminRole void repeatedAnnotations(); + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class) + String preAuthorizeGetCardNumberIfAdmin(String cardNumber); + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StartMaskingHandlerChild.class) + String preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber); + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class) + String preAuthorizeThrowAccessDeniedManually(); + + @PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = CardNumberMaskingPostProcessor.class) + String postAuthorizeGetCardNumberIfAdmin(String cardNumber); + + @PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = PostMaskingPostProcessor.class) + String postAuthorizeThrowAccessDeniedManually(); + + @PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class) + @Mask("methodmask") + String preAuthorizeDeniedMethodWithMaskAnnotation(); + + @PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class) + String preAuthorizeDeniedMethodWithNoMaskAnnotation(); + + @NullDenied(role = "ADMIN") + String postAuthorizeDeniedWithNullDenied(); + + @PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class) + @Mask("methodmask") + String postAuthorizeDeniedMethodWithMaskAnnotation(); + + @PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class) + String postAuthorizeDeniedMethodWithNoMaskAnnotation(); + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = MaskAnnotationHandler.class) + @Mask(expression = "@myMasker.getMask()") + String preAuthorizeWithMaskAnnotationUsingBean(); + + @PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = MaskAnnotationPostProcessor.class) + @Mask(expression = "@myMasker.getMask(returnObject)") + String postAuthorizeWithMaskAnnotationUsingBean(); + + @AuthorizeReturnObject + UserRecordWithEmailProtected getUserRecordWithEmailProtected(); + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = UserFallbackDeniedHandler.class) + UserRecordWithEmailProtected getUserWithFallbackWhenUnauthorized(); + + class StarMaskingHandler implements MethodAuthorizationDeniedHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + return "***"; + } + + } + + class StartMaskingHandlerChild extends StarMaskingHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + return super.handle(methodInvocation, result) + "-child"; + } + + } + + class MaskAnnotationHandler implements MethodAuthorizationDeniedHandler { + + MaskValueResolver maskValueResolver; + + MaskAnnotationHandler(ApplicationContext context) { + this.maskValueResolver = new MaskValueResolver(context); + } + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + Mask mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod(), Mask.class); + if (mask == null) { + mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod().getDeclaringClass(), Mask.class); + } + return this.maskValueResolver.resolveValue(mask, methodInvocation, null); + } + + } + + class MaskAnnotationPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + MaskValueResolver maskValueResolver; + + MaskAnnotationPostProcessor(ApplicationContext context) { + this.maskValueResolver = new MaskValueResolver(context); + } + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, + AuthorizationResult authorizationResult) { + MethodInvocation mi = methodInvocationResult.getMethodInvocation(); + Mask mask = AnnotationUtils.getAnnotation(mi.getMethod(), Mask.class); + if (mask == null) { + mask = AnnotationUtils.getAnnotation(mi.getMethod().getDeclaringClass(), Mask.class); + } + return this.maskValueResolver.resolveValue(mask, mi, methodInvocationResult.getResult()); + } + + } + + class MaskValueResolver { + + DefaultMethodSecurityExpressionHandler expressionHandler; + + MaskValueResolver(ApplicationContext context) { + this.expressionHandler = new DefaultMethodSecurityExpressionHandler(); + this.expressionHandler.setApplicationContext(context); + } + + String resolveValue(Mask mask, MethodInvocation mi, Object returnObject) { + if (StringUtils.hasText(mask.value())) { + return mask.value(); + } + Expression expression = this.expressionHandler.getExpressionParser().parseExpression(mask.expression()); + EvaluationContext evaluationContext = this.expressionHandler + .createEvaluationContext(() -> SecurityContextHolder.getContext().getAuthentication(), mi); + if (returnObject != null) { + this.expressionHandler.setReturnObject(returnObject, evaluationContext); + } + return expression.getValue(evaluationContext, String.class); + } + + } + + class PostMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) { + return "***"; + } + + } + + class CardNumberMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + static String MASK = "****-****-****-"; + + @Override + public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) { + String cardNumber = (String) contextObject.getResult(); + return MASK + cardNumber.substring(cardNumber.length() - 4); + } + + } + + class NullPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, + AuthorizationResult authorizationResult) { + return null; + } + + } + + @Target({ ElementType.METHOD, ElementType.TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @Inherited + @interface Mask { + + String value() default ""; + + String expression() default ""; + + } + + @Target({ ElementType.METHOD, ElementType.TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @Inherited + @PostAuthorize(value = "hasRole('{value}')", postProcessorClass = NullPostProcessor.class) + @interface NullDenied { + + String role(); + + } + + class UserFallbackDeniedHandler implements MethodAuthorizationDeniedHandler { + + private static final UserRecordWithEmailProtected FALLBACK = new UserRecordWithEmailProtected("Protected", + "Protected"); + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) { + return FALLBACK; + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityServiceImpl.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityServiceImpl.java index ebe851d1f39..e44e9e048c6 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityServiceImpl.java +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityServiceImpl.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. @@ -18,6 +18,7 @@ import java.util.List; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -126,4 +127,74 @@ public List allAnnotations(List list) { public void repeatedAnnotations() { } + @Override + public String postAuthorizeGetCardNumberIfAdmin(String cardNumber) { + return cardNumber; + } + + @Override + public String preAuthorizeGetCardNumberIfAdmin(String cardNumber) { + return cardNumber; + } + + @Override + public String preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber) { + return cardNumber; + } + + @Override + public String preAuthorizeThrowAccessDeniedManually() { + throw new AccessDeniedException("Access Denied"); + } + + @Override + public String postAuthorizeThrowAccessDeniedManually() { + throw new AccessDeniedException("Access Denied"); + } + + @Override + public String preAuthorizeDeniedMethodWithMaskAnnotation() { + return "ok"; + } + + @Override + public String preAuthorizeDeniedMethodWithNoMaskAnnotation() { + return "ok"; + } + + @Override + public String postAuthorizeDeniedWithNullDenied() { + return "ok"; + } + + @Override + public String postAuthorizeDeniedMethodWithMaskAnnotation() { + return "ok"; + } + + @Override + public String postAuthorizeDeniedMethodWithNoMaskAnnotation() { + return "ok"; + } + + @Override + public String preAuthorizeWithMaskAnnotationUsingBean() { + return "ok"; + } + + @Override + public String postAuthorizeWithMaskAnnotationUsingBean() { + return "ok"; + } + + @Override + public UserRecordWithEmailProtected getUserRecordWithEmailProtected() { + return new UserRecordWithEmailProtected("username", "useremail@example.com"); + } + + @Override + public UserRecordWithEmailProtected getUserWithFallbackWhenUnauthorized() { + return new UserRecordWithEmailProtected("username", "useremail@example.com"); + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MyMasker.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MyMasker.java new file mode 100644 index 00000000000..a60abd87fdd --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/MyMasker.java @@ -0,0 +1,29 @@ +/* + * 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; + +public class MyMasker { + + public String getMask(String value) { + return value + "-masked"; + } + + public String getMask() { + return "mask"; + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfigurationTests.java index 257c5de40bf..72981bbd83c 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfigurationTests.java @@ -743,6 +743,188 @@ public void findAllWhenNestedPreAuthorizeThenAuthorizes() { }); } + @Test + @WithMockUser + void getCardNumberWhenPostAuthorizeAndNotAdminThenReturnMasked() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + MethodSecurityService.CardNumberMaskingPostProcessor.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String cardNumber = service.postAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111"); + assertThat(cardNumber).isEqualTo("****-****-****-1111"); + } + + @Test + @WithMockUser + void getCardNumberWhenPreAuthorizeAndNotAdminThenReturnMasked() { + this.spring.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.StarMaskingHandler.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String cardNumber = service.preAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111"); + assertThat(cardNumber).isEqualTo("***"); + } + + @Test + @WithMockUser + void getCardNumberWhenPreAuthorizeAndNotAdminAndChildHandlerThenResolveCorrectHandlerAndReturnMasked() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.StarMaskingHandler.class, + MethodSecurityService.StartMaskingHandlerChild.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String cardNumber = service.preAuthorizeWithHandlerChildGetCardNumberIfAdmin("4444-3333-2222-1111"); + assertThat(cardNumber).isEqualTo("***-child"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void preAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPreAuthorizeThenNotHandled() { + this.spring.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.StarMaskingHandler.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(service::preAuthorizeThrowAccessDeniedManually); + } + + @Test + @WithMockUser + void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.preAuthorizeDeniedMethodWithMaskAnnotation(); + assertThat(result).isEqualTo("methodmask"); + } + + @Test + @WithMockUser + void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.preAuthorizeDeniedMethodWithNoMaskAnnotation(); + assertThat(result).isEqualTo("classmask"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void postAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPostAuthorizeThenNotHandled() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.PostMaskingPostProcessor.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(service::postAuthorizeThrowAccessDeniedManually); + } + + @Test + @WithMockUser + void postAuthorizeWhenNullDeniedMetaAnnotationThanWorks() { + this.spring.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.NullPostProcessor.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.postAuthorizeDeniedWithNullDenied(); + assertThat(result).isNull(); + } + + @Test + @WithMockUser + void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.postAuthorizeDeniedMethodWithMaskAnnotation(); + assertThat(result).isEqualTo("methodmask"); + } + + @Test + @WithMockUser + void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.postAuthorizeDeniedMethodWithNoMaskAnnotation(); + assertThat(result).isEqualTo("classmask"); + } + + @Test + @WithMockUser + void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class, + MyMasker.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.postAuthorizeWithMaskAnnotationUsingBean(); + assertThat(result).isEqualTo("ok-masked"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void postAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class, + MyMasker.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.postAuthorizeWithMaskAnnotationUsingBean(); + assertThat(result).isEqualTo("ok"); + } + + @Test + @WithMockUser + void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class, + MyMasker.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.preAuthorizeWithMaskAnnotationUsingBean(); + assertThat(result).isEqualTo("mask"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void preAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class, + MyMasker.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + String result = service.preAuthorizeWithMaskAnnotationUsingBean(); + assertThat(result).isEqualTo("ok"); + } + + @Test + @WithMockUser + void getUserWhenAuthorizedAndUserEmailIsProtectedAndNotAuthorizedThenReturnEmailMasked() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + UserRecordWithEmailProtected.EmailMaskingPostProcessor.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + UserRecordWithEmailProtected user = service.getUserRecordWithEmailProtected(); + assertThat(user.email()).isEqualTo("use******@example.com"); + assertThat(user.name()).isEqualTo("username"); + } + + @Test + @WithMockUser + void getUserWhenNotAuthorizedAndHandlerFallbackValueThenReturnFallbackValue() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.UserFallbackDeniedHandler.class) + .autowire(); + MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class); + UserRecordWithEmailProtected user = service.getUserWithFallbackWhenUnauthorized(); + assertThat(user.email()).isEqualTo("Protected"); + assertThat(user.name()).isEqualTo("Protected"); + } + private static Consumer disallowBeanOverriding() { return (context) -> ((AnnotationConfigWebApplicationContext) context).setAllowBeanDefinitionOverriding(false); } @@ -756,6 +938,16 @@ private static Advisor returnAdvisor(int order) { return advisor; } + @Configuration + static class AuthzConfig { + + @Bean + Authz authz() { + return new Authz(); + } + + } + @Configuration @EnableCustomMethodSecurity static class CustomMethodSecurityServiceConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java new file mode 100644 index 00000000000..b8abf345190 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java @@ -0,0 +1,220 @@ +/* + * 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.test.StepVerifier; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@SecurityTestExecutionListeners +public class PrePostReactiveMethodSecurityConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Test + @WithMockUser + void getCardNumberWhenPostAuthorizeAndNotAdminThenReturnMasked() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.CardNumberMaskingPostProcessor.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.postAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111")) + .expectNext("****-****-****-1111") + .verifyComplete(); + } + + @Test + @WithMockUser + void getCardNumberWhenPreAuthorizeAndNotAdminThenReturnMasked() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.StarMaskingHandler.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.preAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111")) + .expectNext("***") + .verifyComplete(); + } + + @Test + @WithMockUser + void getCardNumberWhenPreAuthorizeAndNotAdminAndChildHandlerThenResolveCorrectHandlerAndReturnMasked() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.StarMaskingHandler.class, + ReactiveMethodSecurityService.StartMaskingHandlerChild.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.preAuthorizeWithHandlerChildGetCardNumberIfAdmin("4444-3333-2222-1111")) + .expectNext("***-child") + .verifyComplete(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void preAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPreAuthorizeThenNotHandled() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.StarMaskingHandler.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.preAuthorizeThrowAccessDeniedManually()) + .expectError(AccessDeniedException.class) + .verify(); + } + + @Test + @WithMockUser + void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationHandler.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.preAuthorizeDeniedMethodWithMaskAnnotation()) + .expectNext("methodmask") + .verifyComplete(); + } + + @Test + @WithMockUser + void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationHandler.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.preAuthorizeDeniedMethodWithNoMaskAnnotation()) + .expectNext("classmask") + .verifyComplete(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void postAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPostAuthorizeThenNotHandled() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.PostMaskingPostProcessor.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.postAuthorizeThrowAccessDeniedManually()) + .expectError(AccessDeniedException.class) + .verify(); + } + + @Test + @WithMockUser + void postAuthorizeWhenNullDeniedMetaAnnotationThanWorks() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.NullPostProcessor.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.postAuthorizeDeniedWithNullDenied()).verifyComplete(); + } + + @Test + @WithMockUser + void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.postAuthorizeDeniedMethodWithMaskAnnotation()) + .expectNext("methodmask") + .verifyComplete(); + } + + @Test + @WithMockUser + void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.postAuthorizeDeniedMethodWithNoMaskAnnotation()) + .expectNext("classmask") + .verifyComplete(); + } + + @Test + @WithMockUser + void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class, MyMasker.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.postAuthorizeWithMaskAnnotationUsingBean()) + .expectNext("ok-masked") + .verifyComplete(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void postAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class, MyMasker.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.postAuthorizeWithMaskAnnotationUsingBean()).expectNext("ok").verifyComplete(); + } + + @Test + @WithMockUser + void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationHandler.class, MyMasker.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.preAuthorizeWithMaskAnnotationUsingBean()).expectNext("mask").verifyComplete(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void preAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, + ReactiveMethodSecurityService.MaskAnnotationHandler.class, MyMasker.class) + .autowire(); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + StepVerifier.create(service.preAuthorizeWithMaskAnnotationUsingBean()).expectNext("ok").verifyComplete(); + } + + @Configuration + @EnableReactiveMethodSecurity + static class MethodSecurityServiceEnabledConfig { + + @Bean + ReactiveMethodSecurityService methodSecurityService() { + return new ReactiveMethodSecurityServiceImpl(); + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java new file mode 100644 index 00000000000..836340a7eec --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java @@ -0,0 +1,222 @@ +/* + * 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.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.aopalliance.intercept.MethodInvocation; +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor; +import org.springframework.security.authorization.method.MethodInvocationResult; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; + +/** + * @author Rob Winch + */ +@ReactiveMethodSecurityService.Mask("classmask") +public interface ReactiveMethodSecurityService { + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class) + Mono preAuthorizeGetCardNumberIfAdmin(String cardNumber); + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StartMaskingHandlerChild.class) + Mono preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber); + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class) + Mono preAuthorizeThrowAccessDeniedManually(); + + @PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = CardNumberMaskingPostProcessor.class) + Mono postAuthorizeGetCardNumberIfAdmin(String cardNumber); + + @PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = PostMaskingPostProcessor.class) + Mono postAuthorizeThrowAccessDeniedManually(); + + @PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class) + @Mask("methodmask") + Mono preAuthorizeDeniedMethodWithMaskAnnotation(); + + @PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class) + Mono preAuthorizeDeniedMethodWithNoMaskAnnotation(); + + @NullDenied(role = "ADMIN") + Mono postAuthorizeDeniedWithNullDenied(); + + @PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class) + @Mask("methodmask") + Mono postAuthorizeDeniedMethodWithMaskAnnotation(); + + @PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class) + Mono postAuthorizeDeniedMethodWithNoMaskAnnotation(); + + @PreAuthorize(value = "hasRole('ADMIN')", handlerClass = MaskAnnotationHandler.class) + @Mask(expression = "@myMasker.getMask()") + Mono preAuthorizeWithMaskAnnotationUsingBean(); + + @PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = MaskAnnotationPostProcessor.class) + @Mask(expression = "@myMasker.getMask(returnObject)") + Mono postAuthorizeWithMaskAnnotationUsingBean(); + + class StarMaskingHandler implements MethodAuthorizationDeniedHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + return "***"; + } + + } + + class StartMaskingHandlerChild extends StarMaskingHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + return super.handle(methodInvocation, result) + "-child"; + } + + } + + class MaskAnnotationHandler implements MethodAuthorizationDeniedHandler { + + MaskValueResolver maskValueResolver; + + MaskAnnotationHandler(ApplicationContext context) { + this.maskValueResolver = new MaskValueResolver(context); + } + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + Mask mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod(), Mask.class); + if (mask == null) { + mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod().getDeclaringClass(), Mask.class); + } + return this.maskValueResolver.resolveValue(mask, methodInvocation, null); + } + + } + + class MaskAnnotationPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + MaskValueResolver maskValueResolver; + + MaskAnnotationPostProcessor(ApplicationContext context) { + this.maskValueResolver = new MaskValueResolver(context); + } + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, + AuthorizationResult authorizationResult) { + MethodInvocation mi = methodInvocationResult.getMethodInvocation(); + Mask mask = AnnotationUtils.getAnnotation(mi.getMethod(), Mask.class); + if (mask == null) { + mask = AnnotationUtils.getAnnotation(mi.getMethod().getDeclaringClass(), Mask.class); + } + return this.maskValueResolver.resolveValue(mask, mi, methodInvocationResult.getResult()); + } + + } + + class MaskValueResolver { + + DefaultMethodSecurityExpressionHandler expressionHandler; + + MaskValueResolver(ApplicationContext context) { + this.expressionHandler = new DefaultMethodSecurityExpressionHandler(); + this.expressionHandler.setApplicationContext(context); + } + + Mono resolveValue(Mask mask, MethodInvocation mi, Object returnObject) { + if (StringUtils.hasText(mask.value())) { + return Mono.just(mask.value()); + } + Expression expression = this.expressionHandler.getExpressionParser().parseExpression(mask.expression()); + EvaluationContext evaluationContext = this.expressionHandler + .createEvaluationContext(() -> SecurityContextHolder.getContext().getAuthentication(), mi); + if (returnObject != null) { + this.expressionHandler.setReturnObject(returnObject, evaluationContext); + } + return Mono.just(expression.getValue(evaluationContext, String.class)); + } + + } + + class PostMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) { + return "***"; + } + + } + + class CardNumberMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + static String MASK = "****-****-****-"; + + @Override + public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) { + String cardNumber = (String) contextObject.getResult(); + return MASK + cardNumber.substring(cardNumber.length() - 4); + } + + } + + class NullPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, + AuthorizationResult authorizationResult) { + return null; + } + + } + + @Target({ ElementType.METHOD, ElementType.TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @Inherited + @interface Mask { + + String value() default ""; + + String expression() default ""; + + } + + @Target({ ElementType.METHOD, ElementType.TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @Inherited + @PostAuthorize(value = "hasRole('{value}')", postProcessorClass = NullPostProcessor.class) + @interface NullDenied { + + String role(); + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java new file mode 100644 index 00000000000..7fb421585aa --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java @@ -0,0 +1,85 @@ +/* + * 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 reactor.core.publisher.Mono; + +import org.springframework.security.access.AccessDeniedException; + +public class ReactiveMethodSecurityServiceImpl implements ReactiveMethodSecurityService { + + @Override + public Mono preAuthorizeGetCardNumberIfAdmin(String cardNumber) { + return Mono.just(cardNumber); + } + + @Override + public Mono preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber) { + return Mono.just(cardNumber); + } + + @Override + public Mono preAuthorizeThrowAccessDeniedManually() { + return Mono.error(new AccessDeniedException("Access Denied")); + } + + @Override + public Mono postAuthorizeGetCardNumberIfAdmin(String cardNumber) { + return Mono.just(cardNumber); + } + + @Override + public Mono postAuthorizeThrowAccessDeniedManually() { + return Mono.error(new AccessDeniedException("Access Denied")); + } + + @Override + public Mono preAuthorizeDeniedMethodWithMaskAnnotation() { + return Mono.just("ok"); + } + + @Override + public Mono preAuthorizeDeniedMethodWithNoMaskAnnotation() { + return Mono.just("ok"); + } + + @Override + public Mono postAuthorizeDeniedWithNullDenied() { + return Mono.just("ok"); + } + + @Override + public Mono postAuthorizeDeniedMethodWithMaskAnnotation() { + return Mono.just("ok"); + } + + @Override + public Mono postAuthorizeDeniedMethodWithNoMaskAnnotation() { + return Mono.just("ok"); + } + + @Override + public Mono preAuthorizeWithMaskAnnotationUsingBean() { + return Mono.just("ok"); + } + + @Override + public Mono postAuthorizeWithMaskAnnotationUsingBean() { + return Mono.just("ok"); + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/UserRecordWithEmailProtected.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/UserRecordWithEmailProtected.java new file mode 100644 index 00000000000..1fe6bf46517 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/UserRecordWithEmailProtected.java @@ -0,0 +1,55 @@ +/* + * 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.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor; +import org.springframework.security.authorization.method.MethodInvocationResult; + +public class UserRecordWithEmailProtected { + + private final String name; + + private final String email; + + public UserRecordWithEmailProtected(String name, String email) { + this.name = name; + this.email = email; + } + + public String name() { + return this.name; + } + + @PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = EmailMaskingPostProcessor.class) + public String email() { + return this.email; + } + + public static class EmailMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, + AuthorizationResult authorizationResult) { + String email = (String) methodInvocationResult.getResult(); + return email.replaceAll("(^[^@]{3}|(?!^)\\G)[^@]", "$1*"); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/access/prepost/PostAuthorize.java b/core/src/main/java/org/springframework/security/access/prepost/PostAuthorize.java index 18a60ef88f8..306a9833093 100644 --- a/core/src/main/java/org/springframework/security/access/prepost/PostAuthorize.java +++ b/core/src/main/java/org/springframework/security/access/prepost/PostAuthorize.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 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. @@ -23,6 +23,9 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor; +import org.springframework.security.authorization.method.ThrowingMethodAuthorizationDeniedPostProcessor; + /** * Annotation for specifying a method access-control expression which will be evaluated * after a method has been invoked. @@ -42,4 +45,10 @@ */ String value(); + /** + * @return the {@link MethodAuthorizationDeniedPostProcessor} class used to + * post-process access denied + */ + Class postProcessorClass() default ThrowingMethodAuthorizationDeniedPostProcessor.class; + } diff --git a/core/src/main/java/org/springframework/security/access/prepost/PreAuthorize.java b/core/src/main/java/org/springframework/security/access/prepost/PreAuthorize.java index ba711030533..9f6cd1d1e0d 100644 --- a/core/src/main/java/org/springframework/security/access/prepost/PreAuthorize.java +++ b/core/src/main/java/org/springframework/security/access/prepost/PreAuthorize.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 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. @@ -23,6 +23,9 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler; +import org.springframework.security.authorization.method.ThrowingMethodAuthorizationDeniedHandler; + /** * Annotation for specifying a method access-control expression which will be evaluated to * decide whether a method invocation is allowed or not. @@ -42,4 +45,10 @@ */ String value(); + /** + * @return the {@link MethodAuthorizationDeniedHandler} class used to handle access + * denied + */ + Class handlerClass() default ThrowingMethodAuthorizationDeniedHandler.class; + } diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java new file mode 100644 index 00000000000..f38ec0d29f2 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java @@ -0,0 +1,43 @@ +/* + * 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 org.springframework.security.access.AccessDeniedException; +import org.springframework.util.Assert; + +/** + * An {@link AccessDeniedException} that contains the {@link AuthorizationResult} + * + * @author Marcus da Coregio + * @since 6.3 + */ +public class AuthorizationDeniedException extends AccessDeniedException { + + private final AuthorizationResult result; + + public AuthorizationDeniedException(String msg, AuthorizationResult authorizationResult) { + super(msg); + Assert.notNull(authorizationResult, "authorizationResult cannot be null"); + Assert.isTrue(!authorizationResult.isGranted(), "Granted authorization results are not supported"); + this.result = authorizationResult; + } + + public AuthorizationResult getAuthorizationResult() { + return this.result; + } + +} 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 4d490515f08..005d6443202 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 @@ -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. @@ -55,6 +55,8 @@ public final class AuthorizationManagerAfterMethodInterceptor implements Authori private final AuthorizationManager authorizationManager; + private final MethodAuthorizationDeniedPostProcessor defaultPostProcessor = new ThrowingMethodAuthorizationDeniedPostProcessor(); + private int order; private AuthorizationEventPublisher eventPublisher = AuthorizationManagerAfterMethodInterceptor::noPublish; @@ -116,8 +118,7 @@ public static AuthorizationManagerAfterMethodInterceptor postAuthorize( @Override public Object invoke(MethodInvocation mi) throws Throwable { Object result = mi.proceed(); - attemptAuthorization(mi, result); - return result; + return attemptAuthorization(mi, result); } @Override @@ -168,7 +169,7 @@ public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy strat this.securityContextHolderStrategy = () -> strategy; } - private void attemptAuthorization(MethodInvocation mi, Object result) { + private Object attemptAuthorization(MethodInvocation mi, Object result) { this.logger.debug(LogMessage.of(() -> "Authorizing method invocation " + mi)); MethodInvocationResult object = new MethodInvocationResult(mi, result); AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, object); @@ -176,9 +177,17 @@ private void attemptAuthorization(MethodInvocation mi, Object result) { if (decision != null && !decision.isGranted()) { this.logger.debug(LogMessage.of(() -> "Failed to authorize " + mi + " with authorization manager " + this.authorizationManager + " and decision " + decision)); - throw new AccessDeniedException("Access Denied"); + return postProcess(object, decision); } this.logger.debug(LogMessage.of(() -> "Authorized method invocation " + mi)); + return result; + } + + private Object postProcess(MethodInvocationResult mi, AuthorizationDecision decision) { + if (decision instanceof MethodAuthorizationDeniedPostProcessor postProcessableDecision) { + return postProcessableDecision.postProcessResult(mi, decision); + } + return this.defaultPostProcessor.postProcessResult(mi, decision); } private Authentication getAuthentication() { 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 ea3d0236d8c..477dc290beb 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 @@ -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. @@ -33,6 +33,7 @@ import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.util.Assert; @@ -57,6 +58,8 @@ public final class AuthorizationManagerAfterReactiveMethodInterceptor implements private int order = AuthorizationInterceptorsOrder.LAST.getOrder(); + private final MethodAuthorizationDeniedPostProcessor defaultPostProcessor = new ThrowingMethodAuthorizationDeniedPostProcessor(); + /** * Creates an instance for the {@link PostAuthorize} annotation. * @return the {@link AuthorizationManagerAfterReactiveMethodInterceptor} to use @@ -144,9 +147,28 @@ private boolean isMultiValue(Class returnType, ReactiveAdapter adapter) { return adapter != null && adapter.isMultiValue(); } - private Mono postAuthorize(Mono authentication, MethodInvocation mi, Object result) { - return this.authorizationManager.verify(authentication, new MethodInvocationResult(mi, result)) - .thenReturn(result); + private Mono postAuthorize(Mono authentication, MethodInvocation mi, Object result) { + MethodInvocationResult invocationResult = new MethodInvocationResult(mi, result); + return this.authorizationManager.check(authentication, invocationResult) + .switchIfEmpty(Mono.just(new AuthorizationDecision(false))) + .flatMap((decision) -> postProcess(decision, invocationResult)); + } + + private Mono postProcess(AuthorizationDecision decision, MethodInvocationResult methodInvocationResult) { + if (decision.isGranted()) { + return Mono.just(methodInvocationResult.getResult()); + } + return Mono.fromSupplier(() -> { + if (decision instanceof MethodAuthorizationDeniedPostProcessor postProcessableDecision) { + return postProcessableDecision.postProcessResult(methodInvocationResult, decision); + } + return this.defaultPostProcessor.postProcessResult(methodInvocationResult, decision); + }).flatMap((processedResult) -> { + if (Mono.class.isAssignableFrom(processedResult.getClass())) { + return (Mono) processedResult; + } + return Mono.justOrEmpty(processedResult); + }); } @Override 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 4d84a55616d..aa96c2fb347 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 @@ -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. @@ -59,6 +59,8 @@ public final class AuthorizationManagerBeforeMethodInterceptor implements Author private final AuthorizationManager authorizationManager; + private final MethodAuthorizationDeniedHandler defaultHandler = new ThrowingMethodAuthorizationDeniedHandler(); + private int order = AuthorizationInterceptorsOrder.FIRST.getOrder(); private AuthorizationEventPublisher eventPublisher = AuthorizationManagerBeforeMethodInterceptor::noPublish; @@ -190,8 +192,7 @@ public static AuthorizationManagerBeforeMethodInterceptor jsr250( */ @Override public Object invoke(MethodInvocation mi) throws Throwable { - attemptAuthorization(mi); - return mi.proceed(); + return attemptAuthorization(mi); } @Override @@ -242,16 +243,24 @@ public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy secur this.securityContextHolderStrategy = () -> securityContextHolderStrategy; } - private void attemptAuthorization(MethodInvocation mi) { + private Object attemptAuthorization(MethodInvocation mi) throws Throwable { this.logger.debug(LogMessage.of(() -> "Authorizing method invocation " + mi)); AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, mi); this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, mi, decision); if (decision != null && !decision.isGranted()) { this.logger.debug(LogMessage.of(() -> "Failed to authorize " + mi + " with authorization manager " + this.authorizationManager + " and decision " + decision)); - throw new AccessDeniedException("Access Denied"); + return handle(mi, decision); } this.logger.debug(LogMessage.of(() -> "Authorized method invocation " + mi)); + return mi.proceed(); + } + + private Object handle(MethodInvocation mi, AuthorizationDecision decision) { + if (decision instanceof MethodAuthorizationDeniedHandler handler) { + return handler.handle(mi, decision); + } + return this.defaultHandler.handle(mi, decision); } private Authentication getAuthentication() { 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 25e43e15883..93bfb0d4a04 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 @@ -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. @@ -32,6 +32,7 @@ import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.util.Assert; @@ -57,6 +58,8 @@ public final class AuthorizationManagerBeforeReactiveMethodInterceptor implement private int order = AuthorizationInterceptorsOrder.FIRST.getOrder(); + private final MethodAuthorizationDeniedHandler defaultHandler = new ThrowingMethodAuthorizationDeniedHandler(); + /** * Creates an instance for the {@link PreAuthorize} annotation. * @return the {@link AuthorizationManagerBeforeReactiveMethodInterceptor} to use @@ -112,31 +115,65 @@ public Object invoke(MethodInvocation mi) throws Throwable { + " must return an instance of org.reactivestreams.Publisher " + "(for example, a Mono or Flux) or the function must be a Kotlin coroutine " + "in order to support Reactor Context"); - Mono authentication = ReactiveAuthenticationUtils.getAuthentication(); ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(type); - Mono preAuthorize = this.authorizationManager.verify(authentication, mi); if (hasFlowReturnType) { if (isSuspendingFunction) { - return preAuthorize.thenMany(Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi))); + return preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi))); } else { Assert.state(adapter != null, () -> "The returnType " + type + " on " + method + " must have a org.springframework.core.ReactiveAdapter registered"); - Flux response = preAuthorize - .thenMany(Flux.defer(() -> adapter.toPublisher(ReactiveMethodInvocationUtils.proceed(mi)))); + Flux response = preAuthorized(mi, + Flux.defer(() -> adapter.toPublisher(ReactiveMethodInvocationUtils.proceed(mi)))); return KotlinDelegate.asFlow(response); } } if (isMultiValue(type, adapter)) { - Publisher publisher = Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)); - Flux result = preAuthorize.thenMany(publisher); + Flux result = preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi))); return (adapter != null) ? adapter.fromPublisher(result) : result; } - Mono publisher = Mono.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)); - Mono result = preAuthorize.then(publisher); + Mono result = preAuthorized(mi, Mono.defer(() -> ReactiveMethodInvocationUtils.proceed(mi))); return (adapter != null) ? adapter.fromPublisher(result) : result; } + private Flux preAuthorized(MethodInvocation mi, Flux mapping) { + Mono authentication = ReactiveAuthenticationUtils.getAuthentication(); + return this.authorizationManager.check(authentication, mi) + .switchIfEmpty(Mono.just(new AuthorizationDecision(false))) + .flatMapMany((decision) -> { + if (decision.isGranted()) { + return mapping; + } + return postProcess(decision, mi); + }); + } + + private Mono preAuthorized(MethodInvocation mi, Mono mapping) { + Mono authentication = ReactiveAuthenticationUtils.getAuthentication(); + return this.authorizationManager.check(authentication, mi) + .switchIfEmpty(Mono.just(new AuthorizationDecision(false))) + .flatMap((decision) -> { + if (decision.isGranted()) { + return mapping; + } + return postProcess(decision, mi); + }); + } + + private Mono postProcess(AuthorizationDecision decision, MethodInvocation mi) { + return Mono.fromSupplier(() -> { + if (decision instanceof MethodAuthorizationDeniedHandler handler) { + return handler.handle(mi, decision); + } + return this.defaultHandler.handle(mi, decision); + }).flatMap((result) -> { + if (Mono.class.isAssignableFrom(result.getClass())) { + return (Mono) result; + } + return Mono.justOrEmpty(result); + }); + } + private boolean isMultiValue(Class returnType, ReactiveAdapter adapter) { if (Flux.class.isAssignableFrom(returnType)) { return true; diff --git a/core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedHandler.java b/core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedHandler.java new file mode 100644 index 00000000000..8ccc797d980 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedHandler.java @@ -0,0 +1,46 @@ +/* + * 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.MethodInvocation; + +import org.springframework.lang.Nullable; +import org.springframework.security.authorization.AuthorizationResult; + +/** + * An interface used to define a strategy to handle denied method invocations + * + * @author Marcus da Coregio + * @since 6.3 + * @see org.springframework.security.access.prepost.PreAuthorize + */ +public interface MethodAuthorizationDeniedHandler { + + /** + * Handle denied method invocations, implementations might either throw an + * {@link org.springframework.security.access.AccessDeniedException} or a replacement + * result instead of invoking the method, e.g. a masked value. + * @param methodInvocation the {@link MethodInvocation} related to the authorization + * denied + * @param authorizationResult the authorization denied result + * @return a replacement result for the denied method invocation, or null, or a + * {@link reactor.core.publisher.Mono} for reactive applications + */ + @Nullable + Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult); + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedPostProcessor.java b/core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedPostProcessor.java new file mode 100644 index 00000000000..a72b2b09e37 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedPostProcessor.java @@ -0,0 +1,46 @@ +/* + * 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.springframework.lang.Nullable; +import org.springframework.security.authorization.AuthorizationResult; + +/** + * An interface to define a strategy to handle denied method invocation results + * + * @author Marcus da Coregio + * @since 6.3 + * @see org.springframework.security.access.prepost.PostAuthorize + */ +public interface MethodAuthorizationDeniedPostProcessor { + + /** + * Post-process the denied result produced by a method invocation, implementations + * might either throw an + * {@link org.springframework.security.access.AccessDeniedException} or return a + * replacement result instead of the denied result, e.g. a masked value. + * @param methodInvocationResult the object containing the method invocation and the + * result produced + * @param authorizationResult the {@link AuthorizationResult} containing the + * authorization denied details + * @return a replacement result for the denied result, or null, or a + * {@link reactor.core.publisher.Mono} for reactive applications + */ + @Nullable + Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult authorizationResult); + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationDecision.java b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationDecision.java new file mode 100644 index 00000000000..136542cebe0 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationDecision.java @@ -0,0 +1,41 @@ +/* + * 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.springframework.expression.Expression; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.ExpressionAuthorizationDecision; +import org.springframework.util.Assert; + +class PostAuthorizeAuthorizationDecision extends ExpressionAuthorizationDecision + implements MethodAuthorizationDeniedPostProcessor { + + private final MethodAuthorizationDeniedPostProcessor postProcessor; + + PostAuthorizeAuthorizationDecision(boolean granted, Expression expression, + MethodAuthorizationDeniedPostProcessor postProcessor) { + super(granted, expression); + Assert.notNull(postProcessor, "postProcessor cannot be null"); + this.postProcessor = postProcessor; + } + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult result) { + return this.postProcessor.postProcessResult(methodInvocationResult, result); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationManager.java index ee8e26d58a8..cd4f4065445 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationManager.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. @@ -20,13 +20,13 @@ import org.aopalliance.intercept.MethodInvocation; +import org.springframework.context.ApplicationContext; import org.springframework.expression.EvaluationContext; import org.springframework.security.access.expression.ExpressionUtils; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationManager; -import org.springframework.security.authorization.ExpressionAuthorizationDecision; import org.springframework.security.core.Authentication; /** @@ -61,6 +61,18 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + /** + * Invokes + * {@link PostAuthorizeExpressionAttributeRegistry#setApplicationContext(ApplicationContext)} + * with the provided {@link ApplicationContext}. + * @param context the {@link ApplicationContext} + * @since 6.3 + * @see PreAuthorizeExpressionAttributeRegistry#setApplicationContext(ApplicationContext) + */ + public void setApplicationContext(ApplicationContext context) { + this.registry.setApplicationContext(context); + } + /** * Determine if an {@link Authentication} has access to the returned object by * evaluating the {@link PostAuthorize} annotation that the {@link MethodInvocation} @@ -76,11 +88,13 @@ public AuthorizationDecision check(Supplier authentication, Meth if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) { return null; } + PostAuthorizeExpressionAttribute postAuthorizeAttribute = (PostAuthorizeExpressionAttribute) attribute; MethodSecurityExpressionHandler expressionHandler = this.registry.getExpressionHandler(); EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, mi.getMethodInvocation()); expressionHandler.setReturnObject(mi.getResult(), ctx); - boolean granted = ExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx); - return new ExpressionAuthorizationDecision(granted, attribute.getExpression()); + boolean granted = ExpressionUtils.evaluateAsBoolean(postAuthorizeAttribute.getExpression(), ctx); + return new PostAuthorizeAuthorizationDecision(granted, postAuthorizeAttribute.getExpression(), + postAuthorizeAttribute.getPostProcessor()); } } diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttribute.java b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttribute.java new file mode 100644 index 00000000000..7c0101d3111 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttribute.java @@ -0,0 +1,42 @@ +/* + * 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.springframework.expression.Expression; +import org.springframework.util.Assert; + +/** + * An {@link ExpressionAttribute} that carries additional properties for + * {@code @PostAuthorize}. + * + * @author Marcus da Coregio + */ +class PostAuthorizeExpressionAttribute extends ExpressionAttribute { + + private final MethodAuthorizationDeniedPostProcessor postProcessor; + + PostAuthorizeExpressionAttribute(Expression expression, MethodAuthorizationDeniedPostProcessor postProcessor) { + super(expression); + Assert.notNull(postProcessor, "postProcessor cannot be null"); + this.postProcessor = postProcessor; + } + + MethodAuthorizationDeniedPostProcessor getPostProcessor() { + return this.postProcessor; + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttributeRegistry.java b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttributeRegistry.java index a7ca347b4aa..f02d4f9d398 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttributeRegistry.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttributeRegistry.java @@ -18,13 +18,16 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.function.Function; import reactor.util.annotation.NonNull; import org.springframework.aop.support.AopUtils; +import org.springframework.context.ApplicationContext; import org.springframework.expression.Expression; import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.util.Assert; /** * For internal use only, as this contract is likely to change. @@ -35,6 +38,14 @@ */ final class PostAuthorizeExpressionAttributeRegistry extends AbstractExpressionAttributeRegistry { + private final MethodAuthorizationDeniedPostProcessor defaultPostProcessor = new ThrowingMethodAuthorizationDeniedPostProcessor(); + + private Function, MethodAuthorizationDeniedPostProcessor> postProcessorResolver; + + PostAuthorizeExpressionAttributeRegistry() { + this.postProcessorResolver = (clazz) -> this.defaultPostProcessor; + } + @NonNull @Override ExpressionAttribute resolveAttribute(Method method, Class targetClass) { @@ -44,7 +55,9 @@ ExpressionAttribute resolveAttribute(Method method, Class targetClass) { return ExpressionAttribute.NULL_ATTRIBUTE; } Expression expression = getExpressionHandler().getExpressionParser().parseExpression(postAuthorize.value()); - return new ExpressionAttribute(expression); + MethodAuthorizationDeniedPostProcessor postProcessor = this.postProcessorResolver + .apply(postAuthorize.postProcessorClass()); + return new PostAuthorizeExpressionAttribute(expression, postProcessor); } private PostAuthorize findPostAuthorizeAnnotation(Method method, Class targetClass) { @@ -53,4 +66,30 @@ private PostAuthorize findPostAuthorizeAnnotation(Method method, Class target return (postAuthorize != null) ? postAuthorize : lookup.apply(targetClass(method, targetClass)); } + /** + * Uses the provided {@link ApplicationContext} to resolve the + * {@link MethodAuthorizationDeniedPostProcessor} from {@link PostAuthorize} + * @param context the {@link ApplicationContext} to use + */ + void setApplicationContext(ApplicationContext context) { + Assert.notNull(context, "context cannot be null"); + this.postProcessorResolver = (postProcessorClass) -> resolvePostProcessor(context, postProcessorClass); + } + + private MethodAuthorizationDeniedPostProcessor resolvePostProcessor(ApplicationContext context, + Class postProcessorClass) { + if (postProcessorClass == this.defaultPostProcessor.getClass()) { + return this.defaultPostProcessor; + } + String[] beanNames = context.getBeanNamesForType(postProcessorClass); + if (beanNames.length == 0) { + throw new IllegalStateException("Could not find a bean of type " + postProcessorClass.getName()); + } + if (beanNames.length > 1) { + throw new IllegalStateException("Expected to find a single bean of type " + postProcessorClass.getName() + + " but found " + Arrays.toString(beanNames)); + } + return context.getBean(beanNames[0], postProcessorClass); + } + } diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java index 1847ba3c1f5..65b91ec81c4 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.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. @@ -19,6 +19,7 @@ import org.aopalliance.intercept.MethodInvocation; import reactor.core.publisher.Mono; +import org.springframework.context.ApplicationContext; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PostAuthorize; @@ -61,6 +62,10 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + public void setApplicationContext(ApplicationContext context) { + this.registry.setApplicationContext(context); + } + /** * Determines if an {@link Authentication} has access to the returned object from the * {@link MethodInvocation} by evaluating an expression from the {@link PostAuthorize} @@ -77,13 +82,14 @@ public Mono check(Mono authentication, Me if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) { return Mono.empty(); } + PostAuthorizeExpressionAttribute postAuthorizeAttribute = (PostAuthorizeExpressionAttribute) attribute; MethodSecurityExpressionHandler expressionHandler = this.registry.getExpressionHandler(); // @formatter:off return authentication .map((auth) -> expressionHandler.createEvaluationContext(auth, mi)) .doOnNext((ctx) -> expressionHandler.setReturnObject(result.getResult(), ctx)) .flatMap((ctx) -> ReactiveExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx)) - .map((granted) -> new ExpressionAttributeAuthorizationDecision(granted, attribute)); + .map((granted) -> new PostAuthorizeAuthorizationDecision(granted, postAuthorizeAttribute.getExpression(), postAuthorizeAttribute.getPostProcessor())); // @formatter:on } diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationDecision.java b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationDecision.java new file mode 100644 index 00000000000..f41e70b05ff --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationDecision.java @@ -0,0 +1,43 @@ +/* + * 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.MethodInvocation; + +import org.springframework.expression.Expression; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.ExpressionAuthorizationDecision; +import org.springframework.util.Assert; + +class PreAuthorizeAuthorizationDecision extends ExpressionAuthorizationDecision + implements MethodAuthorizationDeniedHandler { + + private final MethodAuthorizationDeniedHandler handler; + + PreAuthorizeAuthorizationDecision(boolean granted, Expression expression, + MethodAuthorizationDeniedHandler handler) { + super(granted, expression); + Assert.notNull(handler, "handler cannot be null"); + this.handler = handler; + } + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + return this.handler.handle(methodInvocation, result); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationManager.java index c1f7a6b9e02..fc0a00857b3 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationManager.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. @@ -20,13 +20,13 @@ import org.aopalliance.intercept.MethodInvocation; +import org.springframework.context.ApplicationContext; import org.springframework.expression.EvaluationContext; import org.springframework.security.access.expression.ExpressionUtils; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationManager; -import org.springframework.security.authorization.ExpressionAuthorizationDecision; import org.springframework.security.core.Authentication; /** @@ -61,6 +61,10 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + public void setApplicationContext(ApplicationContext context) { + this.registry.setApplicationContext(context); + } + /** * Determine if an {@link Authentication} has access to a method by evaluating an * expression from the {@link PreAuthorize} annotation that the @@ -76,9 +80,11 @@ public AuthorizationDecision check(Supplier authentication, Meth if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) { return null; } + PreAuthorizeExpressionAttribute preAuthorizeAttribute = (PreAuthorizeExpressionAttribute) attribute; EvaluationContext ctx = this.registry.getExpressionHandler().createEvaluationContext(authentication, mi); - boolean granted = ExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx); - return new ExpressionAuthorizationDecision(granted, attribute.getExpression()); + boolean granted = ExpressionUtils.evaluateAsBoolean(preAuthorizeAttribute.getExpression(), ctx); + return new PreAuthorizeAuthorizationDecision(granted, preAuthorizeAttribute.getExpression(), + preAuthorizeAttribute.getHandler()); } } diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttribute.java b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttribute.java new file mode 100644 index 00000000000..126e2404df4 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttribute.java @@ -0,0 +1,42 @@ +/* + * 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.springframework.expression.Expression; +import org.springframework.util.Assert; + +/** + * An {@link ExpressionAttribute} that carries additional properties for + * {@code @PreAuthorize}. + * + * @author Marcus da Coregio + */ +class PreAuthorizeExpressionAttribute extends ExpressionAttribute { + + private final MethodAuthorizationDeniedHandler handler; + + PreAuthorizeExpressionAttribute(Expression expression, MethodAuthorizationDeniedHandler handler) { + super(expression); + Assert.notNull(handler, "handler cannot be null"); + this.handler = handler; + } + + MethodAuthorizationDeniedHandler getHandler() { + return this.handler; + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttributeRegistry.java b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttributeRegistry.java index 38994412980..6f89c72699d 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttributeRegistry.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttributeRegistry.java @@ -18,13 +18,16 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.function.Function; import reactor.util.annotation.NonNull; import org.springframework.aop.support.AopUtils; +import org.springframework.context.ApplicationContext; import org.springframework.expression.Expression; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.Assert; /** * For internal use only, as this contract is likely to change. @@ -35,6 +38,14 @@ */ final class PreAuthorizeExpressionAttributeRegistry extends AbstractExpressionAttributeRegistry { + private final MethodAuthorizationDeniedHandler defaultHandler = new ThrowingMethodAuthorizationDeniedHandler(); + + private Function, MethodAuthorizationDeniedHandler> handlerResolver; + + PreAuthorizeExpressionAttributeRegistry() { + this.handlerResolver = (clazz) -> this.defaultHandler; + } + @NonNull @Override ExpressionAttribute resolveAttribute(Method method, Class targetClass) { @@ -44,7 +55,8 @@ ExpressionAttribute resolveAttribute(Method method, Class targetClass) { return ExpressionAttribute.NULL_ATTRIBUTE; } Expression expression = getExpressionHandler().getExpressionParser().parseExpression(preAuthorize.value()); - return new ExpressionAttribute(expression); + MethodAuthorizationDeniedHandler handler = this.handlerResolver.apply(preAuthorize.handlerClass()); + return new PreAuthorizeExpressionAttribute(expression, handler); } private PreAuthorize findPreAuthorizeAnnotation(Method method, Class targetClass) { @@ -53,4 +65,30 @@ private PreAuthorize findPreAuthorizeAnnotation(Method method, Class targetCl return (preAuthorize != null) ? preAuthorize : lookup.apply(targetClass(method, targetClass)); } + /** + * Uses the provided {@link ApplicationContext} to resolve the + * {@link MethodAuthorizationDeniedHandler} from {@link PreAuthorize}. + * @param context the {@link ApplicationContext} to use + */ + void setApplicationContext(ApplicationContext context) { + Assert.notNull(context, "context cannot be null"); + this.handlerResolver = (clazz) -> resolveHandler(context, clazz); + } + + private MethodAuthorizationDeniedHandler resolveHandler(ApplicationContext context, + Class handlerClass) { + if (handlerClass == this.defaultHandler.getClass()) { + return this.defaultHandler; + } + String[] beanNames = context.getBeanNamesForType(handlerClass); + if (beanNames.length == 0) { + throw new IllegalStateException("Could not find a bean of type " + handlerClass.getName()); + } + if (beanNames.length > 1) { + throw new IllegalStateException("Expected to find a single bean of type " + handlerClass.getName() + + " but found " + Arrays.toString(beanNames)); + } + return context.getBean(beanNames[0], handlerClass); + } + } diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java index 383a0201130..9a640926d13 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.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. @@ -19,6 +19,7 @@ import org.aopalliance.intercept.MethodInvocation; import reactor.core.publisher.Mono; +import org.springframework.context.ApplicationContext; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PreAuthorize; @@ -60,6 +61,10 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + public void setApplicationContext(ApplicationContext context) { + this.registry.setApplicationContext(context); + } + /** * Determines if an {@link Authentication} has access to the {@link MethodInvocation} * by evaluating an expression from the {@link PreAuthorize} annotation. @@ -74,11 +79,12 @@ public Mono check(Mono authentication, Me if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) { return Mono.empty(); } + PreAuthorizeExpressionAttribute preAuthorizeAttribute = (PreAuthorizeExpressionAttribute) attribute; // @formatter:off return authentication .map((auth) -> this.registry.getExpressionHandler().createEvaluationContext(auth, mi)) .flatMap((ctx) -> ReactiveExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx)) - .map((granted) -> new ExpressionAttributeAuthorizationDecision(granted, attribute)); + .map((granted) -> new PreAuthorizeAuthorizationDecision(granted, preAuthorizeAttribute.getExpression(), preAuthorizeAttribute.getHandler())); // @formatter:on } diff --git a/core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedHandler.java b/core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedHandler.java new file mode 100644 index 00000000000..374df815174 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedHandler.java @@ -0,0 +1,38 @@ +/* + * 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.MethodInvocation; + +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.AuthorizationResult; + +/** + * An implementation of {@link MethodAuthorizationDeniedHandler} that throws + * {@link AuthorizationDeniedException} + * + * @author Marcus da Coregio + * @since 6.3 + */ +public final class ThrowingMethodAuthorizationDeniedHandler implements MethodAuthorizationDeniedHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + throw new AuthorizationDeniedException("Access Denied", result); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedPostProcessor.java b/core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedPostProcessor.java new file mode 100644 index 00000000000..ab871576dae --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedPostProcessor.java @@ -0,0 +1,36 @@ +/* + * 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.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.AuthorizationResult; + +/** + * An implementation of {@link MethodAuthorizationDeniedPostProcessor} that throws + * {@link AuthorizationDeniedException} + * + * @author Marcus da Coregio + * @since 6.3 + */ +public final class ThrowingMethodAuthorizationDeniedPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult result) { + throw new AuthorizationDeniedException("Access Denied", result); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java index 572fd754f4f..bf93e7351c8 100644 --- a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java +++ b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.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. @@ -23,8 +23,12 @@ import reactor.core.publisher.Mono; import org.springframework.aop.Pointcut; +import org.springframework.expression.common.LiteralExpression; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.intercept.method.MockMethodInvocation; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.authorization.ReactiveAuthorizationManager; import static org.assertj.core.api.Assertions.assertThat; @@ -66,14 +70,15 @@ public void invokeMonoWhenMockReactiveAuthorizationManagerThenVerify() throws Th given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), any())).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision(true))); AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block) .isEqualTo("john"); - verify(mockReactiveAuthorizationManager).verify(any(), any()); + verify(mockReactiveAuthorizationManager).check(any(), any()); } @Test @@ -83,7 +88,8 @@ public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Th given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), any())).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision(true))); AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -91,7 +97,7 @@ public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Th .extracting(Flux::collectList) .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) .containsExactly("john", "bob"); - verify(mockReactiveAuthorizationManager, times(2)).verify(any(), any()); + verify(mockReactiveAuthorizationManager, times(2)).check(any(), any()); } @Test @@ -101,8 +107,8 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), any())) - .willReturn(Mono.error(new AccessDeniedException("Access Denied"))); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision(false))); AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -110,7 +116,157 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block)) .withMessage("Access Denied"); - verify(mockReactiveAuthorizationManager).verify(any(), any()); + verify(mockReactiveAuthorizationManager).check(any(), any()); + } + + @Test + public void invokeFluxWhenAllValuesDeniedAndPostProcessorThenPostProcessorAppliedToEachValueEmitted() + throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux"))); + given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), any())) + .will((invocation) -> Mono.just(createDecision(new MaskingPostProcessor()))); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class)) + .extracting(Flux::collectList) + .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) + .containsExactly("john-masked", "bob-masked"); + verify(mockReactiveAuthorizationManager, times(2)).check(any(), any()); + } + + @Test + public void invokeFluxWhenOneValueDeniedAndPostProcessorThenPostProcessorAppliedToDeniedValue() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux"))); + given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), any())).willAnswer((invocation) -> { + MethodInvocationResult argument = invocation.getArgument(1); + if ("john".equals(argument.getResult())) { + return Mono.just(new AuthorizationDecision(true)); + } + return Mono.just(createDecision(new MaskingPostProcessor())); + }); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class)) + .extracting(Flux::collectList) + .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) + .containsExactly("john", "bob-masked"); + verify(mockReactiveAuthorizationManager, times(2)).check(any(), any()); + } + + @Test + public void invokeMonoWhenPostProcessableDecisionThenPostProcess() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + PostAuthorizeAuthorizationDecision decision = new PostAuthorizeAuthorizationDecision(false, + new LiteralExpression("1234"), new MaskingPostProcessor()); + given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.just(decision)); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block) + .isEqualTo("john-masked"); + verify(mockReactiveAuthorizationManager).check(any(), any()); + } + + @Test + public void invokeMonoWhenPostProcessableDecisionAndPostProcessResultIsMonoThenPostProcessWorks() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + PostAuthorizeAuthorizationDecision decision = new PostAuthorizeAuthorizationDecision(false, + new LiteralExpression("1234"), new MonoMaskingPostProcessor()); + given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.just(decision)); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block) + .isEqualTo("john-masked"); + verify(mockReactiveAuthorizationManager).check(any(), any()); + } + + @Test + public void invokeMonoWhenPostProcessableDecisionAndPostProcessResultIsNullThenPostProcessWorks() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + PostAuthorizeAuthorizationDecision decision = new PostAuthorizeAuthorizationDecision(false, + new LiteralExpression("1234"), new NullPostProcessor()); + given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.just(decision)); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block) + .isEqualTo(null); + verify(mockReactiveAuthorizationManager).check(any(), any()); + } + + @Test + public void invokeMonoWhenEmptyDecisionThenUseDefaultPostProcessor() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.empty()); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThatExceptionOfType(AuthorizationDeniedException.class) + .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block)) + .withMessage("Access Denied"); + verify(mockReactiveAuthorizationManager).check(any(), any()); + } + + private PostAuthorizeAuthorizationDecision createDecision(MethodAuthorizationDeniedPostProcessor postProcessor) { + return new PostAuthorizeAuthorizationDecision(false, new LiteralExpression("1234"), postProcessor); + } + + static class MaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) { + return contextObject.getResult() + "-masked"; + } + + } + + static class MonoMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) { + return Mono.just(contextObject.getResult() + "-masked"); + } + + } + + static class NullPostProcessor implements MethodAuthorizationDeniedPostProcessor { + + @Override + public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) { + return null; + } + } class Sample { diff --git a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java index 13f6f405750..d45bd42fa79 100644 --- a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java +++ b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.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. @@ -23,8 +23,12 @@ import reactor.core.publisher.Mono; import org.springframework.aop.Pointcut; +import org.springframework.expression.common.LiteralExpression; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.intercept.method.MockMethodInvocation; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.authorization.ReactiveAuthorizationManager; import static org.assertj.core.api.Assertions.assertThat; @@ -67,14 +71,15 @@ public void invokeMonoWhenMockReactiveAuthorizationManagerThenVerify() throws Th given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision(true))); AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block) .isEqualTo("john"); - verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation)); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); } @Test @@ -84,7 +89,8 @@ public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Th given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision((true)))); AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -92,7 +98,7 @@ public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Th .extracting(Flux::collectList) .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) .containsExactly("john", "bob"); - verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation)); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); } @Test @@ -102,8 +108,8 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))) - .willReturn(Mono.error(new AccessDeniedException("Access Denied"))); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision(false))); AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -111,7 +117,119 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block)) .withMessage("Access Denied"); - verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation)); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); + } + + @Test + public void invokeMonoWhenDeniedAndPostProcessorThenInvokePostProcessor() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + PreAuthorizeAuthorizationDecision decision = new PreAuthorizeAuthorizationDecision(false, + new LiteralExpression("1234"), new MaskingPostProcessor()); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.just(decision)); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block) + .isEqualTo("***"); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); + } + + @Test + public void invokeMonoWhenDeniedAndMonoPostProcessorThenInvokePostProcessor() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + PreAuthorizeAuthorizationDecision decision = new PreAuthorizeAuthorizationDecision(false, + new LiteralExpression("1234"), new MonoMaskingPostProcessor()); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.just(decision)); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block) + .isEqualTo("***"); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); + } + + @Test + public void invokeFluxWhenDeniedAndPostProcessorThenInvokePostProcessor() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux"))); + given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + PreAuthorizeAuthorizationDecision decision = new PreAuthorizeAuthorizationDecision(false, + new LiteralExpression("1234"), new MonoMaskingPostProcessor()); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.just(decision)); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class)) + .extracting(Flux::collectList) + .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) + .containsExactly("***"); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); + } + + @Test + public void invokeMonoWhenEmptyDecisionThenInvokeDefaultPostProcessor() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.empty()); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThatExceptionOfType(AuthorizationDeniedException.class) + .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block)) + .withMessage("Access Denied"); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); + } + + @Test + public void invokeFluxWhenEmptyDecisionThenInvokeDefaultPostProcessor() throws Throwable { + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux"))); + given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.empty()); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + Object result = interceptor.invoke(mockMethodInvocation); + assertThatExceptionOfType(AuthorizationDeniedException.class) + .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class)) + .extracting(Flux::blockFirst)) + .withMessage("Access Denied"); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); + } + + static class MaskingPostProcessor implements MethodAuthorizationDeniedHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + return "***"; + } + + } + + static class MonoMaskingPostProcessor implements MethodAuthorizationDeniedHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) { + return Mono.just("***"); + } + } class Sample { diff --git a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc index d9d71584b95..6bdd1ca4e90 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc @@ -44,6 +44,7 @@ Consider learning about the following use cases: * Understanding <> and reasons to use it * Comparing <> * Authorizing methods with <> and <> +* Providing <> * Filtering methods with <> and <> * Authorizing methods with <> * Authorizing methods with <> @@ -2208,6 +2209,459 @@ And if they do have that authority, they'll see: 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. ==== +[[fallback-values-authorization-denied]] +== Providing Fallback Values When Authorization is Denied + +There are some scenarios where you may not wish to throw an `AccessDeniedException` when a method is invoked without the required permissions. +Instead, you might wish to return a post-processed result, like a masked result, or a default value in cases where access denied happened before invoking the method. + +Spring Security provides support for handling and post-processing method access denied with the <> respectively. +The `@PreAuthorize` annotation works with implementations of `MethodAuthorizationDeniedHandler` while the `@PostAuthorize` annotation works with implementations of `MethodAuthorizationDeniedPostProcessor`. + +=== Using with `@PreAuthorize` + +Let's consider the example from the <>, but instead of creating the `AccessDeniedExceptionInterceptor` to transform an `AccessDeniedException` to a `null` return value, we will use the `handlerClass` attribute from `@PreAuthorize`: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +public class NullMethodAuthorizationDeniedHandler implements MethodAuthorizationDeniedHandler { <1> + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) { + return null; + } + +} + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean <2> + public NullMethodAuthorizationDeniedHandler nullMethodAuthorizationDeniedHandler() { + return new NullMethodAuthorizationDeniedHandler(); + } + +} + +public class User { + // ... + + @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = NullMethodAuthorizationDeniedHandler.class) + public String getEmail() { + return this.email; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +class NullMethodAuthorizationDeniedHandler : MethodAuthorizationDeniedHandler { <1> + + override fun handle(methodInvocation: MethodInvocation, authorizationResult: AuthorizationResult): Any { + return null + } + +} + +@Configuration +@EnableMethodSecurity +class SecurityConfig { + + @Bean <2> + fun nullMethodAuthorizationDeniedHandler(): NullMethodAuthorizationDeniedHandler { + return MaskMethodAuthorizationDeniedHandler() + } + +} + +class User (val name:String, @get:PreAuthorize(value = "hasAuthority('user:read')", handlerClass = NullMethodAuthorizationDeniedHandler::class) val email:String) <3> +---- +====== + +<1> Create an implementation of `MethodAuthorizationDeniedHandler` that returns a `null` value +<2> Register the `NullMethodAuthorizationDeniedHandler` as a bean +<3> Pass the `NullMethodAuthorizationDeniedHandler` to the `handlerClass` attribute of `@PreAuthorize` + +And then you can verify that a `null` value is returned instead of the `AccessDeniedException`: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Autowired +UserRepository users; + +@Test +void getEmailWhenProxiedThenNullEmail() { + Optional securedUser = users.findByName("name"); + assertThat(securedUser.get().getEmail()).isNull(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Autowired +var users:UserRepository? = null + +@Test +fun getEmailWhenProxiedThenNullEmail() { + val securedUser: Optional = users.findByName("name") + assertThat(securedUser.get().getEmail()).isNull() +} +---- +====== + +=== Using with `@PostAuthorize` + +The same can be achieved with `@PostAuthorize`, however, since `@PostAuthorize` checks are performed after the method is invoked, we have access to the resulting value of the invocation, allowing you to provide fallback values based on the unauthorized results. +Let's continue with the previous example, but instead of returning `null`, we will return a masked value of the email: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +public class EmailMaskingMethodAuthorizationDeniedPostProcessor implements MethodAuthorizationDeniedPostProcessor { <1> + + @Override + public Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult authorizationResult) { + String email = (String) methodInvocationResult.getResult(); + return email.replaceAll("(^[^@]{3}|(?!^)\\G)[^@]", "$1*"); + } + +} + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean <2> + public EmailMaskingMethodAuthorizationDeniedPostProcessor emailMaskingMethodAuthorizationDeniedPostProcessor() { + return new EmailMaskingMethodAuthorizationDeniedPostProcessor(); + } + +} + +public class User { + // ... + + @PostAuthorize(value = "hasAuthority('user:read')", postProcessorClass = EmailMaskingMethodAuthorizationDeniedPostProcessor.class) + public String getEmail() { + return this.email; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +class EmailMaskingMethodAuthorizationDeniedPostProcessor : MethodAuthorizationDeniedPostProcessor { + + override fun postProcessResult(methodInvocationResult: MethodInvocationResult, authorizationResult: AuthorizationResult): Any { + val email = methodInvocationResult.result as String + return email.replace("(^[^@]{3}|(?!^)\\G)[^@]".toRegex(), "$1*") + } + +} + +@Configuration +@EnableMethodSecurity +class SecurityConfig { + + @Bean + fun emailMaskingMethodAuthorizationDeniedPostProcessor(): EmailMaskingMethodAuthorizationDeniedPostProcessor { + return EmailMaskingMethodAuthorizationDeniedPostProcessor() + } + +} + +class User (val name:String, @PostAuthorize(value = "hasAuthority('user:read')", postProcessorClass = EmailMaskingMethodAuthorizationDeniedPostProcessor::class) val email:String) <3> +---- +====== + +<1> Create an implementation of `MethodAuthorizationDeniedPostProcessor` that returns a masked value of the unauthorized result value +<2> Register the `EmailMaskingMethodAuthorizationDeniedPostProcessor` as a bean +<3> Pass the `EmailMaskingMethodAuthorizationDeniedPostProcessor` to the `postProcessorClass` attribute of `@PostAuthorize` + +And then you can verify that a masked email is returned instead of an `AccessDeniedException`: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Autowired +UserRepository users; + +@Test +void getEmailWhenProxiedThenMaskedEmail() { + Optional securedUser = users.findByName("name"); + // email is useremail@example.com + assertThat(securedUser.get().getEmail()).isEqualTo("use******@example.com"); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Autowired +var users:UserRepository? = null + +@Test +fun getEmailWhenProxiedThenMaskedEmail() { + val securedUser: Optional = users.findByName("name") + // email is useremail@example.com + assertThat(securedUser.get().getEmail()).isEqualTo("use******@example.com") +} +---- +====== + +When implementing the `MethodAuthorizationDeniedHandler` or the `MethodAuthorizationDeniedPostProcessor` you have a few options on what you can return: + +- A `null` value. +- A non-null value, respecting the method's return type. +- Throw an exception, usually an instance of `AccessDeniedException`. This is the default behavior. +- A `Mono` type for reactive applications. + +Note that since the handler and the post-processor must be registered as beans, you can inject dependencies into them if you need a more complex logic. +In addition to that, you have available the `MethodInvocation` or the `MethodInvocationResult`, as well as the `AuthorizationResult` for more details related to the authorization decision. + +=== Deciding What to Return Based on Available Parameters + +Consider a scenario where there might multiple mask values for different methods, it would be not so productive if we had to create a handler or post-processor for each of those methods, although it is perfectly fine to do that. +In such cases, we can use the information passed via parameters to decide what to do. +For example, we can create a custom `@Mask` annotation and a handler that detects that annotation to decide what mask value to return: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +import org.springframework.core.annotation.AnnotationUtils; + +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface Mask { + + String value(); + +} + +public class MaskAnnotationDeniedHandler implements MethodAuthorizationDeniedHandler { + + @Override + public Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) { + Mask mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod(), Mask.class); + return mask.value(); + } + +} + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public MaskAnnotationDeniedHandler maskAnnotationDeniedHandler() { + return new MaskAnnotationDeniedHandler(); + } + +} + +@Component +public class MyService { + + @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler.class) + @Mask("***") + public String foo() { + return "foo"; + } + + @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler.class) + @Mask("???") + public String bar() { + return "bar"; + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +import org.springframework.core.annotation.AnnotationUtils + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Mask(val value: String) + +class MaskAnnotationDeniedHandler : MethodAuthorizationDeniedHandler { + + override fun handle(methodInvocation: MethodInvocation, authorizationResult: AuthorizationResult): Any { + val mask = AnnotationUtils.getAnnotation(methodInvocation.method, Mask::class.java) + return mask.value + } + +} + +@Configuration +@EnableMethodSecurity +class SecurityConfig { + + @Bean + fun maskAnnotationDeniedHandler(): MaskAnnotationDeniedHandler { + return MaskAnnotationDeniedHandler() + } + +} + +@Component +class MyService { + + @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler::class) + @Mask("***") + fun foo(): String { + return "foo" + } + + @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler::class) + @Mask("???") + fun bar(): String { + return "bar" + } + +} +---- +====== + +Now the return values when access is denied will be decided based on the `@Mask` annotation: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Autowired +MyService myService; + +@Test +void fooWhenDeniedThenReturnStars() { + String value = this.myService.foo(); + assertThat(value).isEqualTo("***"); +} + +@Test +void barWhenDeniedThenReturnQuestionMarks() { + String value = this.myService.foo(); + assertThat(value).isEqualTo("???"); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Autowired +var myService: MyService + +@Test +fun fooWhenDeniedThenReturnStars() { + val value: String = myService.foo() + assertThat(value).isEqualTo("***") +} + +@Test +fun barWhenDeniedThenReturnQuestionMarks() { + val value: String = myService.foo() + assertThat(value).isEqualTo("???") +} +---- +====== + +=== Combining with Meta Annotation Support + +Some authorization expressions may be long enough that it can become hard to read or to maintain. +For example, consider the following `@PreAuthorize` expression: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler.class) +public String myMethod() { + // ... +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler::class) +fun myMethod(): String { + // ... +} +---- +====== + +The way it is, it is somewhat hard to read it, but we can do better. +By using the <>, we can simplify it to: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler.class) +public @interface NullDenied {} + +@NullDenied +public String myMethod() { + // ... +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler::class) +annotation class NullDenied + +@NullDenied +fun myMethod(): String { + // ... +} +---- +====== + +Make sure to read the <> section for more details on the usage. + [[migration-enableglobalmethodsecurity]] == Migrating from `@EnableGlobalMethodSecurity` diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index d71b1c501a6..e29ac0e1146 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -16,6 +16,7 @@ Below are the highlights of the release. - 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 - https://github.com/spring-projects/spring-security/issues/14597[gh-14597] - xref:servlet/authorization/method-security.adoc[docs] - Add Securing of Return Values +- https://github.com/spring-projects/spring-security/issues/14601[gh-14601] - xref:servlet/authorization/method-security.adoc#fallback-values-authorization-denied[docs] - Add Authorization Denied Handlers for Method Security == Configuration