From f755ee38c1377905920f521cbe0b236a19fe07a8 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 14 Mar 2024 11:20:33 -0600 Subject: [PATCH] Add AuthorizationReturnObject Closes gh-14597 --- .../aspectj/AuthorizeReturnObjectAspect.aj | 42 ++++ .../AuthorizationProxyConfiguration.java | 18 +- .../MethodSecurityAdvisorRegistrar.java | 1 + ...thodSecurityAspectJAutoProxyRegistrar.java | 3 + ...ePostMethodSecurityConfigurationTests.java | 194 ++++++++++++++ ...ctiveMethodSecurityConfigurationTests.java | 237 ++++++++++++++++++ .../AuthorizationAdvisorProxyFactory.java | 16 +- .../AuthorizationInterceptorsOrder.java | 6 +- .../AuthorizationProxyMethodInterceptor.java | 103 ++++++++ .../method/AuthorizeReturnObject.java | 28 +++ 10 files changed, 643 insertions(+), 5 deletions(-) create mode 100644 aspects/src/main/java/org/springframework/security/authorization/method/aspectj/AuthorizeReturnObjectAspect.aj create mode 100644 core/src/main/java/org/springframework/security/authorization/method/AuthorizationProxyMethodInterceptor.java create mode 100644 core/src/main/java/org/springframework/security/authorization/method/AuthorizeReturnObject.java diff --git a/aspects/src/main/java/org/springframework/security/authorization/method/aspectj/AuthorizeReturnObjectAspect.aj b/aspects/src/main/java/org/springframework/security/authorization/method/aspectj/AuthorizeReturnObjectAspect.aj new file mode 100644 index 00000000000..1c6c9f29d4d --- /dev/null +++ b/aspects/src/main/java/org/springframework/security/authorization/method/aspectj/AuthorizeReturnObjectAspect.aj @@ -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.aspectj; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.security.authorization.method.AuthorizeReturnObject; + +/** + * Concrete AspectJ aspect using Spring Security @AuthorizeReturnObject annotation. + * + *

+ * When using this aspect, you must annotate the implementation class + * (and/or methods within that class), not the interface (if any) that + * the class implements. AspectJ follows Java's rule that annotations on + * interfaces are not inherited. This will vary from Spring AOP. + * + * @author Josh Cummings + * @since 6.3 + */ +public aspect AuthorizeReturnObjectAspect extends AbstractMethodInterceptorAspect { + + /** + * Matches the execution of any method with a AuthorizeReturnObject annotation. + */ + protected pointcut executionOfAnnotatedMethod() : execution(* *(..)) && @annotation(AuthorizeReturnObject); +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java index 6aa247c6670..04276bce1a9 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java @@ -19,6 +19,8 @@ import java.util.ArrayList; import java.util.List; +import org.aopalliance.intercept.MethodInterceptor; + import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; @@ -28,17 +30,29 @@ import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.security.authorization.AuthorizationAdvisorProxyFactory; import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.authorization.method.AuthorizationProxyMethodInterceptor; +import org.springframework.security.config.Customizer; @Configuration(proxyBeanMethods = false) final class AuthorizationProxyConfiguration implements AopInfrastructureBean { @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static AuthorizationAdvisorProxyFactory authorizationProxyFactory(ObjectProvider provider) { + static AuthorizationAdvisorProxyFactory authorizationProxyFactory(ObjectProvider provider, + ObjectProvider> customizers) { List advisors = new ArrayList<>(); provider.forEach(advisors::add); AnnotationAwareOrderComparator.sort(advisors); - return new AuthorizationAdvisorProxyFactory(advisors); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(advisors); + customizers.forEach((customizer) -> customizer.customize(factory)); + return factory; + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static MethodInterceptor authorizationProxyMethodInterceptor( + AuthorizationAdvisorProxyFactory authorizationProxyFactory) { + return new AuthorizationProxyMethodInterceptor(authorizationProxyFactory); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAdvisorRegistrar.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAdvisorRegistrar.java index 409f6fa1ea2..2495188b866 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAdvisorRegistrar.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAdvisorRegistrar.java @@ -33,6 +33,7 @@ public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, B registerAsAdvisor("postAuthorizeAuthorization", registry); registerAsAdvisor("securedAuthorization", registry); registerAsAdvisor("jsr250Authorization", registry); + registerAsAdvisor("authorizationProxy", registry); } private void registerAsAdvisor(String prefix, BeanDefinitionRegistry registry) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAspectJAutoProxyRegistrar.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAspectJAutoProxyRegistrar.java index 16a31924d9a..8961bf99697 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAspectJAutoProxyRegistrar.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityAspectJAutoProxyRegistrar.java @@ -60,6 +60,9 @@ public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, B "postAuthorizeAspect$0", registry); registerBeanDefinition("securedAuthorizationMethodInterceptor", "org.springframework.security.authorization.method.aspectj.SecuredAspect", "securedAspect$0", registry); + registerBeanDefinition("authorizeReturnObjectMethodInterceptor", + "org.springframework.security.authorization.method.aspectj.AuthorizeReturnObjectAspect", + "authorizeReturnObjectAspect$0", registry); } private void registerBeanDefinition(String beanName, String aspectClassName, String aspectBeanName, 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 f653d3c5875..8f2f8b76c9a 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 @@ -21,7 +21,10 @@ import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.function.Supplier; @@ -55,13 +58,16 @@ 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.AuthorizationAdvisorProxyFactory; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationEventPublisher; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder; import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; +import org.springframework.security.authorization.method.AuthorizeReturnObject; import org.springframework.security.authorization.method.MethodInvocationResult; import org.springframework.security.authorization.method.PrePostTemplateDefaults; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig; import org.springframework.security.config.core.GrantedAuthorityDefaults; import org.springframework.security.config.test.SpringTestContext; @@ -80,6 +86,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; @@ -662,6 +669,79 @@ public void methodWhenPostFilterMetaAnnotationThenFilters() { .containsExactly("dave"); } + @Test + @WithMockUser(authorities = "airplane:read") + public void findByIdWhenAuthorizedResultThenAuthorizes() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + Flight flight = flights.findById("1"); + assertThatNoException().isThrownBy(flight::getAltitude); + assertThatNoException().isThrownBy(flight::getSeats); + } + + @Test + @WithMockUser(authorities = "seating:read") + public void findByIdWhenUnauthorizedResultThenDenies() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + Flight flight = flights.findById("1"); + assertThatNoException().isThrownBy(flight::getSeats); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude); + } + + @Test + @WithMockUser(authorities = "seating:read") + public void findAllWhenUnauthorizedResultThenDenies() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll().forEachRemaining((flight) -> { + assertThatNoException().isThrownBy(flight::getSeats); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude); + }); + } + + @Test + public void removeWhenAuthorizedResultThenRemoves() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.remove("1"); + } + + @Test + @WithMockUser(authorities = "airplane:read") + public void findAllWhenPostFilterThenFilters() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll() + .forEachRemaining((flight) -> assertThat(flight.getPassengers()).extracting(Passenger::getName) + .doesNotContain("Kevin Mitnick")); + } + + @Test + @WithMockUser(authorities = "airplane:read") + public void findAllWhenPreFilterThenFilters() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll().forEachRemaining((flight) -> { + flight.board(new ArrayList<>(List.of("John"))); + assertThat(flight.getPassengers()).extracting(Passenger::getName).doesNotContain("John"); + flight.board(new ArrayList<>(List.of("John Doe"))); + assertThat(flight.getPassengers()).extracting(Passenger::getName).contains("John Doe"); + }); + } + + @Test + @WithMockUser(authorities = "seating:read") + public void findAllWhenNestedPreAuthorizeThenAuthorizes() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll().forEachRemaining((flight) -> { + List passengers = flight.getPassengers(); + passengers.forEach((passenger) -> assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(passenger::getName)); + }); + } + private static Consumer disallowBeanOverriding() { return (context) -> ((AnnotationConfigWebApplicationContext) context).setAllowBeanDefinitionOverriding(false); } @@ -1061,4 +1141,118 @@ List resultsContainDave(List list) { } + @EnableMethodSecurity + @Configuration + static class AuthorizeResultConfig { + + @Bean + static Customizer returnObject() { + return (proxyFactory) -> proxyFactory.setSkipProxy(AuthorizationAdvisorProxyFactory.SKIP_VALUE_TYPES); + } + + @Bean + FlightRepository flights() { + FlightRepository flights = new FlightRepository(); + Flight one = new Flight("1", 35000d, 35); + one.board(new ArrayList<>(List.of("Marie Curie", "Kevin Mitnick", "Ada Lovelace"))); + flights.save(one); + Flight two = new Flight("2", 32000d, 72); + two.board(new ArrayList<>(List.of("Albert Einstein"))); + flights.save(two); + return flights; + } + + @Bean + RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.withRolePrefix("").role("airplane:read").implies("seating:read").build(); + } + + } + + @AuthorizeReturnObject + static class FlightRepository { + + private final Map flights = new ConcurrentHashMap<>(); + + Iterator findAll() { + return this.flights.values().iterator(); + } + + Flight findById(String id) { + return this.flights.get(id); + } + + Flight save(Flight flight) { + this.flights.put(flight.getId(), flight); + return flight; + } + + void remove(String id) { + this.flights.remove(id); + } + + } + + @AuthorizeReturnObject + static class Flight { + + private final String id; + + private final Double altitude; + + private final Integer seats; + + private final List passengers = new ArrayList<>(); + + Flight(String id, Double altitude, Integer seats) { + this.id = id; + this.altitude = altitude; + this.seats = seats; + } + + String getId() { + return this.id; + } + + @PreAuthorize("hasAuthority('airplane:read')") + Double getAltitude() { + return this.altitude; + } + + @PreAuthorize("hasAuthority('seating:read')") + Integer getSeats() { + return this.seats; + } + + @PostAuthorize("hasAuthority('seating:read')") + @PostFilter("filterObject.name != 'Kevin Mitnick'") + List getPassengers() { + return this.passengers; + } + + @PreAuthorize("hasAuthority('seating:read')") + @PreFilter("filterObject.contains(' ')") + void board(List passengers) { + for (String passenger : passengers) { + this.passengers.add(new Passenger(passenger)); + } + } + + } + + public static class Passenger { + + String name; + + public Passenger(String name) { + this.name = name; + } + + @PreAuthorize("hasAuthority('airplane:read')") + public String getName() { + return this.name; + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityConfigurationTests.java index 79149fd41c3..713d1008a52 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityConfigurationTests.java @@ -16,20 +16,38 @@ package org.springframework.security.config.annotation.method.configuration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.expression.EvaluationContext; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.expression.SecurityExpressionRoot; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.intercept.method.MockMethodInvocation; +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.authentication.TestingAuthenticationToken; +import org.springframework.security.authorization.AuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.method.AuthorizeReturnObject; +import org.springframework.security.config.Customizer; import org.springframework.security.config.core.GrantedAuthorityDefaults; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; import static org.assertj.core.api.Assertions.assertThat; @@ -85,6 +103,112 @@ public void rolePrefixWithGrantedAuthorityDefaultsAndSubclassWithProxyingEnabled assertThat(root.hasRole("ABC")).isTrue(); } + @Test + public void findByIdWhenAuthorizedResultThenAuthorizes() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + TestingAuthenticationToken pilot = new TestingAuthenticationToken("user", "pass", "airplane:read"); + StepVerifier + .create(flights.findById("1") + .flatMap(Flight::getAltitude) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .expectNextCount(1) + .verifyComplete(); + StepVerifier + .create(flights.findById("1") + .flatMap(Flight::getSeats) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + public void findByIdWhenUnauthorizedResultThenDenies() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + TestingAuthenticationToken pilot = new TestingAuthenticationToken("user", "pass", "seating:read"); + StepVerifier + .create(flights.findById("1") + .flatMap(Flight::getSeats) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .expectNextCount(1) + .verifyComplete(); + StepVerifier + .create(flights.findById("1") + .flatMap(Flight::getAltitude) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .verifyError(AccessDeniedException.class); + } + + @Test + public void findAllWhenUnauthorizedResultThenDenies() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + TestingAuthenticationToken pilot = new TestingAuthenticationToken("user", "pass", "seating:read"); + StepVerifier + .create(flights.findAll() + .flatMap(Flight::getSeats) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .expectNextCount(2) + .verifyComplete(); + StepVerifier + .create(flights.findAll() + .flatMap(Flight::getAltitude) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .verifyError(AccessDeniedException.class); + } + + @Test + public void removeWhenAuthorizedResultThenRemoves() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + TestingAuthenticationToken pilot = new TestingAuthenticationToken("user", "pass", "seating:read"); + StepVerifier.create(flights.remove("1").contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .verifyComplete(); + } + + @Test + public void findAllWhenPostFilterThenFilters() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + TestingAuthenticationToken pilot = new TestingAuthenticationToken("user", "pass", "airplane:read"); + StepVerifier + .create(flights.findAll() + .flatMap(Flight::getPassengers) + .flatMap(Passenger::getName) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .expectNext("Marie Curie", "Ada Lovelace", "Albert Einstein") + .verifyComplete(); + } + + @Test + public void findAllWhenPreFilterThenFilters() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + TestingAuthenticationToken pilot = new TestingAuthenticationToken("user", "pass", "airplane:read"); + StepVerifier + .create(flights.findAll() + .flatMap((flight) -> flight.board(Flux.just("John Doe", "John")).then(Mono.just(flight))) + .flatMap(Flight::getPassengers) + .flatMap(Passenger::getName) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .expectNext("Marie Curie", "Ada Lovelace", "John Doe", "Albert Einstein", "John Doe") + .verifyComplete(); + } + + @Test + public void findAllWhenNestedPreAuthorizeThenAuthorizes() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + TestingAuthenticationToken pilot = new TestingAuthenticationToken("user", "pass", "seating:read"); + StepVerifier + .create(flights.findAll() + .flatMap(Flight::getPassengers) + .flatMap(Passenger::getName) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(pilot))) + .verifyError(AccessDeniedException.class); + } + @Configuration @EnableReactiveMethodSecurity // this imports ReactiveMethodSecurityConfiguration static class WithRolePrefixConfiguration { @@ -108,4 +232,117 @@ public void bar(String param) { } + @EnableReactiveMethodSecurity + @Configuration + static class AuthorizeResultConfig { + + @Bean + static Customizer returnObject() { + return (proxyFactory) -> proxyFactory.setSkipProxy(AuthorizationAdvisorProxyFactory.SKIP_VALUE_TYPES); + } + + @Bean + FlightRepository flights() { + FlightRepository flights = new FlightRepository(); + Flight one = new Flight("1", 35000d, 35); + one.board(Flux.just("Marie Curie", "Kevin Mitnick", "Ada Lovelace")).block(); + flights.save(one).block(); + Flight two = new Flight("2", 32000d, 72); + two.board(Flux.just("Albert Einstein")).block(); + flights.save(two).block(); + return flights; + } + + @Bean + Function> isNotKevin() { + return (passenger) -> passenger.getName().map((name) -> !name.equals("Kevin Mitnick")); + } + + } + + @AuthorizeReturnObject + static class FlightRepository { + + private final Map flights = new ConcurrentHashMap<>(); + + Flux findAll() { + return Flux.fromIterable(this.flights.values()); + } + + Mono findById(String id) { + return Mono.just(this.flights.get(id)); + } + + Mono save(Flight flight) { + this.flights.put(flight.getId(), flight); + return Mono.just(flight); + } + + Mono remove(String id) { + this.flights.remove(id); + return Mono.empty(); + } + + } + + @AuthorizeReturnObject + static class Flight { + + private final String id; + + private final Double altitude; + + private final Integer seats; + + private final List passengers = new ArrayList<>(); + + Flight(String id, Double altitude, Integer seats) { + this.id = id; + this.altitude = altitude; + this.seats = seats; + } + + String getId() { + return this.id; + } + + @PreAuthorize("hasAuthority('airplane:read')") + Mono getAltitude() { + return Mono.just(this.altitude); + } + + @PreAuthorize("hasAnyAuthority('seating:read', 'airplane:read')") + Mono getSeats() { + return Mono.just(this.seats); + } + + @PostAuthorize("hasAnyAuthority('seating:read', 'airplane:read')") + @PostFilter("@isNotKevin.apply(filterObject)") + Flux getPassengers() { + return Flux.fromIterable(this.passengers); + } + + @PreAuthorize("hasAnyAuthority('seating:read', 'airplane:read')") + @PreFilter("filterObject.contains(' ')") + Mono board(Flux passengers) { + return passengers.doOnNext((passenger) -> this.passengers.add(new Passenger(passenger))).then(); + } + + } + + public static class Passenger { + + String name; + + public Passenger(String name) { + this.name = name; + } + + @PreAuthorize("hasAuthority('airplane:read')") + public Mono getName() { + return Mono.just(this.name); + } + + } + } diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java index 9706cbe959a..513b8ef1e6d 100644 --- a/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java @@ -34,6 +34,7 @@ import java.util.SortedSet; import java.util.TreeMap; import java.util.TreeSet; +import java.util.function.Predicate; import java.util.stream.Stream; import reactor.core.publisher.Flux; @@ -74,8 +75,12 @@ */ public final class AuthorizationAdvisorProxyFactory implements AuthorizationProxyFactory { + public static final Predicate> SKIP_VALUE_TYPES = ClassUtils::isSimpleValueType; + private final Collection advisors; + private Predicate> skipProxy = (returnType) -> false; + public AuthorizationAdvisorProxyFactory(AuthorizationAdvisor... advisors) { this.advisors = List.of(advisors); } @@ -98,7 +103,9 @@ public AuthorizationAdvisorProxyFactory withAdvisors(AuthorizationAdvisor... adv merged.addAll(this.advisors); merged.addAll(List.of(advisors)); AnnotationAwareOrderComparator.sort(merged); - return new AuthorizationAdvisorProxyFactory(merged); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(merged); + factory.setSkipProxy(this.skipProxy); + return factory; } /** @@ -124,6 +131,9 @@ public Object proxy(Object target) { if (target == null) { return null; } + if (this.skipProxy.test(target.getClass())) { + return target; + } if (target instanceof Class targetClass) { return proxyClass(targetClass); } @@ -174,6 +184,10 @@ public Object proxy(Object target) { return factory.getProxy(); } + public void setSkipProxy(Predicate> skipProxy) { + this.skipProxy = skipProxy; + } + @SuppressWarnings("unchecked") private T proxyCast(T target) { return (T) proxy(target); diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationInterceptorsOrder.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationInterceptorsOrder.java index da6a26bf6e0..c92d39db185 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationInterceptorsOrder.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationInterceptorsOrder.java @@ -43,12 +43,14 @@ public enum AuthorizationInterceptorsOrder { JSR250, - POST_AUTHORIZE, + SECURE_RESULT(450), + + POST_AUTHORIZE(500), /** * {@link PostFilterAuthorizationMethodInterceptor} */ - POST_FILTER, + POST_FILTER(600), LAST(Integer.MAX_VALUE); diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationProxyMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationProxyMethodInterceptor.java new file mode 100644 index 00000000000..8185203a35b --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationProxyMethodInterceptor.java @@ -0,0 +1,103 @@ +/* + * 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 java.lang.reflect.Method; +import java.util.function.Predicate; + +import org.aopalliance.aop.Advice; +import org.aopalliance.intercept.MethodInvocation; + +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.Pointcuts; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.security.authorization.AuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.AuthorizationProxyFactory; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +public final class AuthorizationProxyMethodInterceptor implements AuthorizationAdvisor { + + private final AuthorizationProxyFactory authorizationProxyFactory; + + private Pointcut pointcut = Pointcuts.intersection( + new MethodReturnTypePointcut(Predicate.not(ClassUtils::isVoidType)), + AuthorizationMethodPointcuts.forAnnotations(AuthorizeReturnObject.class)); + + private int order = AuthorizationInterceptorsOrder.SECURE_RESULT.getOrder(); + + public AuthorizationProxyMethodInterceptor(AuthorizationAdvisorProxyFactory authorizationProxyFactory) { + Assert.notNull(authorizationProxyFactory, "authorizationManager cannot be null"); + this.authorizationProxyFactory = authorizationProxyFactory.withAdvisors(this); + } + + @Override + public Object invoke(MethodInvocation mi) throws Throwable { + Object result = mi.proceed(); + if (result == null) { + return null; + } + return this.authorizationProxyFactory.proxy(result); + } + + @Override + public int getOrder() { + return this.order; + } + + public void setOrder(int order) { + this.order = order; + } + + /** + * {@inheritDoc} + */ + @Override + public Pointcut getPointcut() { + return this.pointcut; + } + + public void setPointcut(Pointcut pointcut) { + this.pointcut = pointcut; + } + + @Override + public Advice getAdvice() { + return this; + } + + @Override + public boolean isPerInstance() { + return true; + } + + static final class MethodReturnTypePointcut extends StaticMethodMatcherPointcut { + + private final Predicate> returnTypeMatches; + + MethodReturnTypePointcut(Predicate> returnTypeMatches) { + this.returnTypeMatches = returnTypeMatches; + } + + @Override + public boolean matches(Method method, Class targetClass) { + return this.returnTypeMatches.test(method.getReturnType()); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizeReturnObject.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizeReturnObject.java new file mode 100644 index 00000000000..38c256c29be --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizeReturnObject.java @@ -0,0 +1,28 @@ +/* + * 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 java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface AuthorizeReturnObject { + +}