diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyWebConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyWebConfiguration.java new file mode 100644 index 0000000000..4af062ef96 --- /dev/null +++ b/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 model = (Map) proxyFactory.proxy(mav.getModel()); + ModelAndView proxied = (view != null) ? new ModelAndView(view, model) + : new ModelAndView(viewName, model); + proxied.setStatus(mav.getStatus()); + return proxied; + } + return null; + } + + } + +} 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 47d5d23f76..ed2c7cb03e 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-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()); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java index c204b33aaa..8bf18bf3eb 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java @@ -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()); } 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 d07e4484a7..297564fa0d 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 @@ -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 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 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 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 index b82eb985bc..32e08c166a 100644 --- 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 @@ -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 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> 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 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