Ver código fonte

Add Support for Authorizing Spring MVC Return Types

Closes gh-16059
Josh Cummings 4 meses atrás
pai
commit
09ba5397fb

+ 65 - 0
config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyWebConfiguration.java

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2002-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.method.configuration;
+
+import java.util.Map;
+
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Role;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory;
+import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.servlet.View;
+
+@Configuration
+class AuthorizationProxyWebConfiguration {
+
+	@Bean
+	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+	AuthorizationAdvisorProxyFactory.TargetVisitor webTargetVisitor() {
+		return new WebTargetVisitor();
+	}
+
+	static class WebTargetVisitor implements AuthorizationAdvisorProxyFactory.TargetVisitor {
+
+		@Override
+		public Object visit(AuthorizationAdvisorProxyFactory proxyFactory, Object target) {
+			if (target instanceof ResponseEntity<?> entity) {
+				return new ResponseEntity<>(proxyFactory.proxy(entity.getBody()), entity.getHeaders(),
+						entity.getStatusCode());
+			}
+			if (target instanceof HttpEntity<?> entity) {
+				return new HttpEntity<>(proxyFactory.proxy(entity.getBody()), entity.getHeaders());
+			}
+			if (target instanceof ModelAndView mav) {
+				View view = mav.getView();
+				String viewName = mav.getViewName();
+				Map<String, Object> model = (Map<String, Object>) proxyFactory.proxy(mav.getModel());
+				ModelAndView proxied = (view != null) ? new ModelAndView(view, model)
+						: new ModelAndView(viewName, model);
+				proxied.setStatus(mav.getStatus());
+				return proxied;
+			}
+			return null;
+		}
+
+	}
+
+}

+ 7 - 1
config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -41,6 +41,9 @@ final class MethodSecuritySelector implements ImportSelector {
 	private static final boolean isDataPresent = ClassUtils
 		.isPresent("org.springframework.security.data.aot.hint.AuthorizeReturnObjectDataHintsRegistrar", null);
 
+	private static final boolean isWebPresent = ClassUtils
+		.isPresent("org.springframework.web.servlet.DispatcherServlet", null);
+
 	private static final boolean isObservabilityPresent = ClassUtils
 		.isPresent("io.micrometer.observation.ObservationRegistry", null);
 
@@ -67,6 +70,9 @@ final class MethodSecuritySelector implements ImportSelector {
 		if (isDataPresent) {
 			imports.add(AuthorizationProxyDataConfiguration.class.getName());
 		}
+		if (isWebPresent) {
+			imports.add(AuthorizationProxyWebConfiguration.class.getName());
+		}
 		if (isObservabilityPresent) {
 			imports.add(MethodObservationConfiguration.class.getName());
 		}

+ 7 - 1
config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2025 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,6 +38,9 @@ class ReactiveMethodSecuritySelector implements ImportSelector {
 	private static final boolean isDataPresent = ClassUtils
 		.isPresent("org.springframework.security.data.aot.hint.AuthorizeReturnObjectDataHintsRegistrar", null);
 
+	private static final boolean isWebPresent = ClassUtils.isPresent("org.springframework.web.server.ServerWebExchange",
+			null);
+
 	private static final boolean isObservabilityPresent = ClassUtils
 		.isPresent("io.micrometer.observation.ObservationRegistry", null);
 
@@ -61,6 +64,9 @@ class ReactiveMethodSecuritySelector implements ImportSelector {
 		if (isDataPresent) {
 			imports.add(AuthorizationProxyDataConfiguration.class.getName());
 		}
+		if (isWebPresent) {
+			imports.add(AuthorizationProxyWebConfiguration.class.getName());
+		}
 		if (isObservabilityPresent) {
 			imports.add(ReactiveMethodObservationConfiguration.class.getName());
 		}

+ 64 - 3
config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfigurationTests.java

@@ -60,6 +60,8 @@ import org.springframework.context.annotation.Role;
 import org.springframework.context.event.EventListener;
 import org.springframework.core.annotation.AnnotationAwareOrderComparator;
 import org.springframework.core.annotation.AnnotationConfigurationException;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
 import org.springframework.security.access.AccessDeniedException;
 import org.springframework.security.access.PermissionEvaluator;
 import org.springframework.security.access.annotation.BusinessService;
@@ -90,7 +92,6 @@ import org.springframework.security.authorization.method.AuthorizeReturnObject;
 import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler;
 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.observation.SecurityObservationSettings;
@@ -109,6 +110,7 @@ import org.springframework.test.context.TestExecutionListeners;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 import org.springframework.web.context.ConfigurableWebApplicationContext;
 import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+import org.springframework.web.servlet.ModelAndView;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -729,6 +731,49 @@ public class PrePostMethodSecurityConfigurationTests {
 		assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude);
 	}
 
+	@Test
+	@WithMockUser(authorities = "airplane:read")
+	public void findByIdWhenAuthorizedResponseEntityThenAuthorizes() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = flights.webFindById("1").getBody();
+		assertThatNoException().isThrownBy(flight::getAltitude);
+		assertThatNoException().isThrownBy(flight::getSeats);
+		assertThat(flights.webFindById("5").getBody()).isNull();
+	}
+
+	@Test
+	@WithMockUser(authorities = "seating:read")
+	public void findByIdWhenUnauthorizedResponseEntityThenDenies() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = flights.webFindById("1").getBody();
+		assertThatNoException().isThrownBy(flight::getSeats);
+		assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude);
+	}
+
+	@Test
+	@WithMockUser(authorities = "airplane:read")
+	public void findByIdWhenAuthorizedModelAndViewThenAuthorizes() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = (Flight) flights.webViewFindById("1").getModel().get("flight");
+		assertThatNoException().isThrownBy(flight::getAltitude);
+		assertThatNoException().isThrownBy(flight::getSeats);
+		assertThat(flights.webViewFindById("5").getModel().get("flight")).isNull();
+	}
+
+	@Test
+	@WithMockUser(authorities = "seating:read")
+	public void findByIdWhenUnauthorizedModelAndViewThenDenies() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = (Flight) flights.webViewFindById("1").getModel().get("flight");
+		assertThatNoException().isThrownBy(flight::getSeats);
+		assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude);
+		assertThat(flights.webViewFindById("5").getModel().get("flight")).isNull();
+	}
+
 	@Test
 	@WithMockUser(authorities = "seating:read")
 	public void findAllWhenUnauthorizedResultThenDenies() {
@@ -1601,8 +1646,8 @@ public class PrePostMethodSecurityConfigurationTests {
 
 		@Bean
 		@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
-		static Customizer<AuthorizationAdvisorProxyFactory> skipValueTypes() {
-			return (f) -> f.setTargetVisitor(TargetVisitor.defaultsSkipValueTypes());
+		static TargetVisitor skipValueTypes() {
+			return TargetVisitor.defaultsSkipValueTypes();
 		}
 
 		@Bean
@@ -1646,6 +1691,22 @@ public class PrePostMethodSecurityConfigurationTests {
 			this.flights.remove(id);
 		}
 
+		ResponseEntity<Flight> webFindById(String id) {
+			Flight flight = this.flights.get(id);
+			if (flight == null) {
+				return ResponseEntity.notFound().build();
+			}
+			return ResponseEntity.ok(flight);
+		}
+
+		ModelAndView webViewFindById(String id) {
+			Flight flight = this.flights.get(id);
+			if (flight == null) {
+				return new ModelAndView("error", HttpStatusCode.valueOf(404));
+			}
+			return new ModelAndView("flights", Map.of("flight", flight));
+		}
+
 	}
 
 	@AuthorizeReturnObject

+ 65 - 4
config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -40,6 +40,8 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProce
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Role;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
 import org.springframework.security.access.AccessDeniedException;
 import org.springframework.security.access.PermissionEvaluator;
 import org.springframework.security.access.annotation.Secured;
@@ -54,9 +56,9 @@ import org.springframework.security.access.prepost.PreFilter;
 import org.springframework.security.authorization.AuthorizationDeniedException;
 import org.springframework.security.authorization.method.AuthorizationAdvisor;
 import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory;
+import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory.TargetVisitor;
 import org.springframework.security.authorization.method.AuthorizeReturnObject;
 import org.springframework.security.authorization.method.PrePostTemplateDefaults;
-import org.springframework.security.config.Customizer;
 import org.springframework.security.config.test.SpringTestContext;
 import org.springframework.security.config.test.SpringTestContextExtension;
 import org.springframework.security.core.Authentication;
@@ -65,6 +67,7 @@ import org.springframework.security.test.context.annotation.SecurityTestExecutio
 import org.springframework.security.test.context.support.WithMockUser;
 import org.springframework.stereotype.Component;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.web.servlet.ModelAndView;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -361,6 +364,48 @@ public class PrePostReactiveMethodSecurityConfigurationTests {
 		assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> flight.getAltitude().block());
 	}
 
+	@Test
+	@WithMockUser(authorities = "airplane:read")
+	public void findByIdWhenAuthorizedResponseEntityThenAuthorizes() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = flights.webFindById("1").block().getBody();
+		assertThatNoException().isThrownBy(() -> flight.getAltitude().block());
+		assertThatNoException().isThrownBy(() -> flight.getSeats().block());
+	}
+
+	@Test
+	@WithMockUser(authorities = "seating:read")
+	public void findByIdWhenUnauthorizedResponseEntityThenDenies() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = flights.webFindById("1").block().getBody();
+		assertThatNoException().isThrownBy(() -> flight.getSeats().block());
+		assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> flight.getAltitude().block());
+	}
+
+	@Test
+	@WithMockUser(authorities = "airplane:read")
+	public void findByIdWhenAuthorizedModelAndViewThenAuthorizes() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = (Flight) flights.webViewFindById("1").block().getModel().get("flight");
+		assertThatNoException().isThrownBy(() -> flight.getAltitude().block());
+		assertThatNoException().isThrownBy(() -> flight.getSeats().block());
+		assertThat(flights.webViewFindById("5").block().getModel().get("flight")).isNull();
+	}
+
+	@Test
+	@WithMockUser(authorities = "seating:read")
+	public void findByIdWhenUnauthorizedModelAndViewThenDenies() {
+		this.spring.register(AuthorizeResultConfig.class).autowire();
+		FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class);
+		Flight flight = (Flight) flights.webViewFindById("1").block().getModel().get("flight");
+		assertThatNoException().isThrownBy(() -> flight.getSeats().block());
+		assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> flight.getAltitude().block());
+		assertThat(flights.webViewFindById("5").block().getModel().get("flight")).isNull();
+	}
+
 	@Test
 	@WithMockUser(authorities = "seating:read")
 	public void findAllWhenUnauthorizedResultThenDenies() {
@@ -659,8 +704,8 @@ public class PrePostReactiveMethodSecurityConfigurationTests {
 
 		@Bean
 		@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
-		static Customizer<AuthorizationAdvisorProxyFactory> skipValueTypes() {
-			return (f) -> f.setTargetVisitor(AuthorizationAdvisorProxyFactory.TargetVisitor.defaultsSkipValueTypes());
+		static TargetVisitor skipValueTypes() {
+			return TargetVisitor.defaultsSkipValueTypes();
 		}
 
 		@Bean
@@ -724,6 +769,22 @@ public class PrePostReactiveMethodSecurityConfigurationTests {
 			return Mono.empty();
 		}
 
+		Mono<ResponseEntity<Flight>> webFindById(String id) {
+			Flight flight = this.flights.get(id);
+			if (flight == null) {
+				return Mono.just(ResponseEntity.notFound().build());
+			}
+			return Mono.just(ResponseEntity.ok(flight));
+		}
+
+		Mono<ModelAndView> webViewFindById(String id) {
+			Flight flight = this.flights.get(id);
+			if (flight == null) {
+				return Mono.just(new ModelAndView("error", HttpStatusCode.valueOf(404)));
+			}
+			return Mono.just(new ModelAndView("flights", Map.of("flight", flight)));
+		}
+
 	}
 
 	@AuthorizeReturnObject