diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java new file mode 100644 index 0000000000..6aa247c667 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.annotation.method.configuration; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.aop.framework.AopInfrastructureBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.security.authorization.AuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.method.AuthorizationAdvisor; + +@Configuration(proxyBeanMethods = false) +final class AuthorizationProxyConfiguration implements AopInfrastructureBean { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static AuthorizationAdvisorProxyFactory authorizationProxyFactory(ObjectProvider provider) { + List advisors = new ArrayList<>(); + provider.forEach(advisors::add); + AnnotationAwareOrderComparator.sort(advisors); + return new AuthorizationAdvisorProxyFactory(advisors); + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java index 39567ddfdc..45908fb549 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java @@ -20,6 +20,7 @@ import io.micrometer.observation.ObservationRegistry; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; @@ -48,7 +49,7 @@ import org.springframework.security.core.context.SecurityContextHolderStrategy; */ @Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) -final class Jsr250MethodSecurityConfiguration implements ImportAware { +final class Jsr250MethodSecurityConfiguration implements ImportAware, AopInfrastructureBean { private int interceptorOrderOffset; diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java index 4b561360a7..928ed48548 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java @@ -56,6 +56,7 @@ final class MethodSecuritySelector implements ImportSelector { if (annotation.jsr250Enabled()) { imports.add(Jsr250MethodSecurityConfiguration.class.getName()); } + imports.add(AuthorizationProxyConfiguration.class.getName()); return imports.toArray(new String[0]); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java index 4c10dc4e5b..7fea76850d 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java @@ -27,7 +27,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; @@ -36,7 +35,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportAware; import org.springframework.context.annotation.Role; -import org.springframework.core.Ordered; import org.springframework.core.type.AnnotationMetadata; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; @@ -44,6 +42,7 @@ import org.springframework.security.access.hierarchicalroles.NullRoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.authorization.AuthorizationEventPublisher; import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationAdvisor; import org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.authorization.method.PostAuthorizeAuthorizationManager; @@ -65,7 +64,7 @@ import org.springframework.util.function.SingletonSupplier; */ @Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) -final class PrePostMethodSecurityConfiguration implements ImportAware { +final class PrePostMethodSecurityConfiguration implements ImportAware, AopInfrastructureBean { private int interceptorOrderOffset; @@ -175,8 +174,8 @@ final class PrePostMethodSecurityConfiguration implements ImportAware { this.interceptorOrderOffset = annotation.offset(); } - private static final class DeferringMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { + private static final class DeferringMethodInterceptor + implements AuthorizationAdvisor { private final Pointcut pointcut; diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java index a190938878..2b6a2e2928 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java @@ -20,6 +20,7 @@ import io.micrometer.observation.ObservationRegistry; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.framework.AopInfrastructureBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; @@ -48,7 +49,7 @@ import org.springframework.security.core.context.SecurityContextHolderStrategy; */ @Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) -final class SecuredMethodSecurityConfiguration implements ImportAware { +final class SecuredMethodSecurityConfiguration implements ImportAware, AopInfrastructureBean { private int interceptorOrderOffset; diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfigurationTests.java new file mode 100644 index 0000000000..e3e41c0316 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfigurationTests.java @@ -0,0 +1,92 @@ +/* + * 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 org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.AuthorizationProxyFactory; +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link PrePostMethodSecurityConfiguration}. + * + * @author Evgeniy Cheban + * @author Josh Cummings + */ +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@SecurityTestExecutionListeners +public class AuthorizationProxyConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + AuthorizationProxyFactory proxyFactory; + + @WithMockUser + @Test + public void proxyWhenNotPreAuthorizedThenDenies() { + this.spring.register(DefaultsConfig.class).autowire(); + Toaster toaster = (Toaster) this.proxyFactory.proxy(new Toaster()); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(toaster::makeToast) + .withMessage("Access Denied"); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(toaster::extractBread) + .withMessage("Access Denied"); + } + + @WithMockUser(roles = "ADMIN") + @Test + public void proxyWhenPreAuthorizedThenAllows() { + this.spring.register(DefaultsConfig.class).autowire(); + Toaster toaster = (Toaster) this.proxyFactory.proxy(new Toaster()); + toaster.makeToast(); + assertThat(toaster.extractBread()).isEqualTo("yummy"); + } + + @EnableMethodSecurity + @Configuration + static class DefaultsConfig { + + } + + static class Toaster { + + @PreAuthorize("hasRole('ADMIN')") + void makeToast() { + + } + + @PostAuthorize("hasRole('ADMIN')") + String extractBread() { + return "yummy"; + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java new file mode 100644 index 0000000000..3fbb8df980 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactory.java @@ -0,0 +1,308 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.authorization; + +import java.lang.reflect.Array; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; + +import org.springframework.aop.Advisor; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.util.ClassUtils; + +/** + * A proxy factory for applying authorization advice to an arbitrary object. + * + *

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

+ *     class Foo {
+ *         @PreAuthorize("hasAuthority('bar:read')")
+ *         String bar() { ... }
+ *     }
+ * 
+ * + * Use {@link AuthorizationAdvisorProxyFactory} to wrap the instance in Spring Security's + * {@link org.springframework.security.access.prepost.PreAuthorize} method interceptor + * like so: + * + *
+ *     AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor.preAuthorize();
+ *     AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize);
+ *     Foo foo = new Foo();
+ *     foo.bar(); // passes
+ *     Foo securedFoo = proxyFactory.proxy(foo);
+ *     securedFoo.bar(); // access denied!
+ * 
+ * + * @author Josh Cummings + * @since 6.3 + */ +public final class AuthorizationAdvisorProxyFactory implements AuthorizationProxyFactory { + + private final Collection advisors; + + public AuthorizationAdvisorProxyFactory(AuthorizationAdvisor... advisors) { + this.advisors = List.of(advisors); + } + + public AuthorizationAdvisorProxyFactory(Collection advisors) { + this.advisors = List.copyOf(advisors); + } + + /** + * Create a new {@link AuthorizationAdvisorProxyFactory} that includes the given + * advisors in addition to any advisors {@code this} instance already has. + * + *

+ * All advisors are re-sorted by their advisor order. + * @param advisors the advisors to add + * @return a new {@link AuthorizationAdvisorProxyFactory} instance + */ + public AuthorizationAdvisorProxyFactory withAdvisors(AuthorizationAdvisor... advisors) { + List merged = new ArrayList<>(this.advisors.size() + advisors.length); + merged.addAll(this.advisors); + merged.addAll(List.of(advisors)); + AnnotationAwareOrderComparator.sort(merged); + return new AuthorizationAdvisorProxyFactory(merged); + } + + /** + * Proxy an object to enforce authorization advice. + * + *

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

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

+ * If {@code target} is a {@link Class}, then {@link ProxyFactory#getProxyClass} is + * invoked instead. + * @param target the instance to proxy + * @return the proxied instance + */ + @Override + public Object proxy(Object target) { + if (target == null) { + return null; + } + if (target instanceof Class targetClass) { + return proxyClass(targetClass); + } + if (target instanceof Iterator iterator) { + return proxyIterator(iterator); + } + if (target instanceof Queue queue) { + return proxyQueue(queue); + } + if (target instanceof List list) { + return proxyList(list); + } + if (target instanceof SortedSet set) { + return proxySortedSet(set); + } + if (target instanceof Set set) { + return proxySet(set); + } + if (target.getClass().isArray()) { + return proxyArray((Object[]) target); + } + if (target instanceof SortedMap map) { + return proxySortedMap(map); + } + if (target instanceof Iterable iterable) { + return proxyIterable(iterable); + } + if (target instanceof Map map) { + return proxyMap(map); + } + if (target instanceof Stream stream) { + return proxyStream(stream); + } + if (target instanceof Optional optional) { + return proxyOptional(optional); + } + ProxyFactory factory = new ProxyFactory(target); + for (Advisor advisor : this.advisors) { + factory.addAdvisors(advisor); + } + factory.setProxyTargetClass(!Modifier.isFinal(target.getClass().getModifiers())); + return factory.getProxy(); + } + + @SuppressWarnings("unchecked") + private T proxyCast(T target) { + return (T) proxy(target); + } + + private Class proxyClass(Class targetClass) { + ProxyFactory factory = new ProxyFactory(); + factory.setTargetClass(targetClass); + factory.setInterfaces(ClassUtils.getAllInterfacesForClass(targetClass)); + factory.setProxyTargetClass(!Modifier.isFinal(targetClass.getModifiers())); + for (Advisor advisor : this.advisors) { + factory.addAdvisors(advisor); + } + return factory.getProxyClass(getClass().getClassLoader()); + } + + private Iterable proxyIterable(Iterable iterable) { + return () -> proxyIterator(iterable.iterator()); + } + + private Iterator proxyIterator(Iterator iterator) { + return new Iterator<>() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public T next() { + return proxyCast(iterator.next()); + } + }; + } + + private SortedSet proxySortedSet(SortedSet set) { + SortedSet proxies = new TreeSet<>(set.comparator()); + for (T toProxy : set) { + proxies.add(proxyCast(toProxy)); + } + try { + set.clear(); + set.addAll(proxies); + return proxies; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableSortedSet(proxies); + } + } + + private Set proxySet(Set set) { + Set proxies = new LinkedHashSet<>(set.size()); + for (T toProxy : set) { + proxies.add(proxyCast(toProxy)); + } + try { + set.clear(); + set.addAll(proxies); + return proxies; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableSet(proxies); + } + } + + private Queue proxyQueue(Queue queue) { + Queue proxies = new LinkedList<>(); + for (T toProxy : queue) { + proxies.add(proxyCast(toProxy)); + } + queue.clear(); + queue.addAll(proxies); + return proxies; + } + + private List proxyList(List list) { + List proxies = new ArrayList<>(list.size()); + for (T toProxy : list) { + proxies.add(proxyCast(toProxy)); + } + try { + list.clear(); + list.addAll(proxies); + return proxies; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableList(proxies); + } + } + + private Object[] proxyArray(Object[] objects) { + List retain = new ArrayList<>(objects.length); + for (Object object : objects) { + retain.add(proxy(object)); + } + Object[] proxies = (Object[]) Array.newInstance(objects.getClass().getComponentType(), retain.size()); + for (int i = 0; i < retain.size(); i++) { + proxies[i] = retain.get(i); + } + return proxies; + } + + private SortedMap proxySortedMap(SortedMap entries) { + SortedMap proxies = new TreeMap<>(entries.comparator()); + for (Map.Entry entry : entries.entrySet()) { + proxies.put(entry.getKey(), proxyCast(entry.getValue())); + } + try { + entries.clear(); + entries.putAll(proxies); + return entries; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableSortedMap(proxies); + } + } + + private Map proxyMap(Map entries) { + Map proxies = new LinkedHashMap<>(entries.size()); + for (Map.Entry entry : entries.entrySet()) { + proxies.put(entry.getKey(), proxyCast(entry.getValue())); + } + try { + entries.clear(); + entries.putAll(proxies); + return entries; + } + catch (UnsupportedOperationException ex) { + return Collections.unmodifiableMap(proxies); + } + } + + private Stream proxyStream(Stream stream) { + return stream.map(this::proxy).onClose(stream::close); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private Optional proxyOptional(Optional optional) { + return optional.map(this::proxy); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationProxyFactory.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationProxyFactory.java new file mode 100644 index 0000000000..0aa4069cb8 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationProxyFactory.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.authorization; + +/** + * A factory for wrapping arbitrary objects in authorization-related advice + * + * @author Josh Cummings + * @since 6.3 + * @see AuthorizationAdvisorProxyFactory + */ +public interface AuthorizationProxyFactory { + + /** + * Wrap the given {@code object} in authorization-related advice. + * + *

+ * Please check the implementation for which kinds of objects it supports. + * @param object the object to proxy + * @return the proxied object + * @throws org.springframework.aop.framework.AopConfigException if a proxy cannot be + * created + */ + Object proxy(Object object); + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisor.java new file mode 100644 index 0000000000..deca6b0440 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisor.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.authorization.method; + +import org.aopalliance.intercept.MethodInterceptor; + +import org.springframework.aop.PointcutAdvisor; +import org.springframework.aop.framework.AopInfrastructureBean; +import org.springframework.core.Ordered; + +/** + * An interface that indicates method security advice + * + * @author Josh Cummings + * @since 6.3 + * @see AuthorizationManagerBeforeMethodInterceptor + * @see AuthorizationManagerAfterMethodInterceptor + * @see PreFilterAuthorizationMethodInterceptor + * @see PostFilterAuthorizationMethodInterceptor + */ +public interface AuthorizationAdvisor extends Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java index 2971589e33..4d490515f0 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java @@ -25,9 +25,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.core.log.LogMessage; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PostAuthorize; @@ -48,8 +45,7 @@ import org.springframework.util.Assert; * @author Josh Cummings * @since 5.6 */ -public final class AuthorizationManagerAfterMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class AuthorizationManagerAfterMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java index 0f38826d13..4d84a55616 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java @@ -28,9 +28,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.core.log.LogMessage; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.annotation.Secured; @@ -52,8 +49,7 @@ import org.springframework.util.Assert; * @author Josh Cummings * @since 5.6 */ -public final class AuthorizationManagerBeforeMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class AuthorizationManagerBeforeMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java index aadc75c003..aa96de670d 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationMethodInterceptor.java @@ -23,9 +23,6 @@ import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.expression.EvaluationContext; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PostFilter; @@ -43,8 +40,7 @@ import org.springframework.security.core.context.SecurityContextHolderStrategy; * @author Josh Cummings * @since 5.6 */ -public final class PostFilterAuthorizationMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class PostFilterAuthorizationMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java index 39ae4e257c..a00e22f253 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationMethodInterceptor.java @@ -23,9 +23,6 @@ import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.springframework.aop.Pointcut; -import org.springframework.aop.PointcutAdvisor; -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.core.Ordered; import org.springframework.expression.EvaluationContext; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PreFilter; @@ -44,8 +41,7 @@ import org.springframework.util.StringUtils; * @author Josh Cummings * @since 5.6 */ -public final class PreFilterAuthorizationMethodInterceptor - implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean { +public final class PreFilterAuthorizationMethodInterceptor implements AuthorizationAdvisor { private Supplier securityContextHolderStrategy = SecurityContextHolder::getContextHolderStrategy; diff --git a/core/src/test/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactoryTests.java b/core/src/test/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactoryTests.java new file mode 100644 index 0000000000..2a7852b28c --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/AuthorizationAdvisorProxyFactoryTests.java @@ -0,0 +1,431 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.authorization; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import org.springframework.aop.Pointcut; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class AuthorizationAdvisorProxyFactoryTests { + + private final Authentication user = TestAuthentication.authenticatedUser(); + + private final Authentication admin = TestAuthentication.authenticatedAdmin(); + + private final Flight flight = new Flight(); + + private final User alan = new User("alan", "alan", "turing"); + + @Test + public void proxyWhenPreAuthorizeThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Flight flight = new Flight(); + assertThat(flight.getAltitude()).isEqualTo(35000d); + Flight secured = proxy(factory, flight); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::getAltitude); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeOnInterfaceThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + assertThat(this.alan.getFirstName()).isEqualTo("alan"); + User secured = proxy(factory, this.alan); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::getFirstName); + SecurityContextHolder.getContext().setAuthentication(authenticated("alan")); + assertThat(secured.getFirstName()).isEqualTo("alan"); + SecurityContextHolder.getContext().setAuthentication(this.admin); + assertThat(secured.getFirstName()).isEqualTo("alan"); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeOnRecordThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + HasSecret repo = new Repository("secret"); + assertThat(repo.secret()).isEqualTo("secret"); + HasSecret secured = proxy(factory, repo); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::secret); + SecurityContextHolder.getContext().setAuthentication(this.user); + assertThat(repo.secret()).isEqualTo("secret"); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableListThenReturnsSecuredImmutableList() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + List flights = List.of(this.flight); + List secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableSetThenReturnsSecuredImmutableSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Set flights = Set.of(this.flight); + Set secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenQueueThenReturnsSecuredQueue() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Queue flights = new LinkedList<>(List.of(this.flight)); + Queue secured = proxy(factory, flights); + assertThat(flights.size()).isEqualTo(secured.size()); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableSortedSetThenReturnsSecuredImmutableSortedSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + SortedSet users = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of(this.alan))); + SortedSet secured = proxy(factory, users); + secured + .forEach((user) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getFirstName)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableSortedMapThenReturnsSecuredImmutableSortedMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + SortedMap users = Collections + .unmodifiableSortedMap(new TreeMap<>(Map.of(this.alan.getId(), this.alan))); + SortedMap secured = proxy(factory, users); + secured.forEach( + (id, user) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getFirstName)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenImmutableMapThenReturnsSecuredImmutableMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Map users = Map.of(this.alan.getId(), this.alan); + Map secured = proxy(factory, users); + secured.forEach( + (id, user) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getFirstName)); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(secured::clear); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableListThenReturnsSecuredMutableList() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + List flights = new ArrayList<>(List.of(this.flight)); + List secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableSetThenReturnsSecuredMutableSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Set flights = new HashSet<>(Set.of(this.flight)); + Set secured = proxy(factory, flights); + secured.forEach( + (flight) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(flight::getAltitude)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableSortedSetThenReturnsSecuredMutableSortedSet() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + SortedSet users = new TreeSet<>(Set.of(this.alan)); + SortedSet secured = proxy(factory, users); + secured.forEach((u) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(u::getFirstName)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableSortedMapThenReturnsSecuredMutableSortedMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + SortedMap users = new TreeMap<>(Map.of(this.alan.getId(), this.alan)); + SortedMap secured = proxy(factory, users); + secured.forEach((id, u) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(u::getFirstName)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenMutableMapThenReturnsSecuredMutableMap() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Map users = new HashMap<>(Map.of(this.alan.getId(), this.alan)); + Map secured = proxy(factory, users); + secured.forEach((id, u) -> assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(u::getFirstName)); + secured.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForOptionalThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Optional flights = Optional.of(this.flight); + assertThat(flights.get().getAltitude()).isEqualTo(35000d); + Optional secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.ifPresent(Flight::getAltitude)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForStreamThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Stream flights = Stream.of(this.flight); + Stream secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.forEach(Flight::getAltitude)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForArrayThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Flight[] flights = { this.flight }; + Flight[] secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured[0]::getAltitude); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForIteratorThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Iterator flights = List.of(this.flight).iterator(); + Iterator secured = proxy(factory, flights); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.next().getAltitude()); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForIterableThenHonors() { + SecurityContextHolder.getContext().setAuthentication(this.user); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Iterable users = new UserRepository(); + Iterable secured = proxy(factory, users); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> secured.forEach(User::getFirstName)); + SecurityContextHolder.clearContext(); + } + + @Test + public void proxyWhenPreAuthorizeForClassThenHonors() { + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(preAuthorize); + Class clazz = proxy(factory, Flight.class); + assertThat(clazz.getSimpleName()).contains("SpringCGLIB$$0"); + Flight secured = proxy(factory, this.flight); + assertThat(secured.getClass()).isSameAs(clazz); + SecurityContextHolder.getContext().setAuthentication(this.user); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(secured::getAltitude); + SecurityContextHolder.clearContext(); + } + + @Test + public void withAdvisorsWhenProxyThenVisits() { + AuthorizationAdvisor advisor = mock(AuthorizationAdvisor.class); + given(advisor.getAdvice()).willReturn(advisor); + given(advisor.getPointcut()).willReturn(Pointcut.TRUE); + AuthorizationAdvisorProxyFactory factory = new AuthorizationAdvisorProxyFactory(); + factory = factory.withAdvisors(advisor); + Flight flight = proxy(factory, this.flight); + flight.getAltitude(); + verify(advisor, atLeastOnce()).getPointcut(); + } + + private Authentication authenticated(String user, String... authorities) { + return TestAuthentication.authenticated(TestAuthentication.withUsername(user).authorities(authorities).build()); + } + + private T proxy(AuthorizationProxyFactory factory, Object target) { + return (T) factory.proxy(target); + } + + static class Flight { + + @PreAuthorize("hasRole('PILOT')") + Double getAltitude() { + return 35000d; + } + + } + + interface Identifiable { + + @PreAuthorize("authentication.name == this.id || hasRole('ADMIN')") + String getFirstName(); + + @PreAuthorize("authentication.name == this.id || hasRole('ADMIN')") + String getLastName(); + + } + + public static class User implements Identifiable, Comparable { + + private final String id; + + private final String firstName; + + private final String lastName; + + User(String id, String firstName, String lastName) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + } + + public String getId() { + return this.id; + } + + @Override + public String getFirstName() { + return this.firstName; + } + + @Override + public String getLastName() { + return this.lastName; + } + + @Override + public int compareTo(@NotNull User that) { + return this.id.compareTo(that.getId()); + } + + } + + static class UserRepository implements Iterable { + + List users = List.of(new User("1", "first", "last")); + + @NotNull + @Override + public Iterator iterator() { + return this.users.iterator(); + } + + } + + interface HasSecret { + + String secret(); + + } + + record Repository(@PreAuthorize("hasRole('ADMIN')") String secret) implements HasSecret { + } + +} diff --git a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc index ccd328e7d5..62d956ce2b 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc @@ -1702,6 +1702,397 @@ This works on both classes and interfaces. This does not work for interfaces, since they do not have debug information about the parameter names. For interfaces, either annotations or the `-parameters` approach must be used. +[[authorize-object]] +== Authorizing Arbitrary Objects + +Spring Security also supports wrapping any object that is annotated its method security annotations. + +To achieve this, you can autowire the provided `AuthorizationProxyFactory` instance, which is based on which method security interceptors you have configured. +If you are using `@EnableMethodSecurity`, then this means that it will by default have the interceptors for `@PreAuthorize`, `@PostAuthorize`, `@PreFilter`, and `@PostFilter`. + +For example, consider the following `User` class: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +public class User { + private String name; + private String email; + + public User(String name, String email) { + this.name = name; + this.email = email; + } + + public String getName() { + return this.name; + } + + @PreAuthorize("hasAuthority('user:read')") + public String getEmail() { + return this.email; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +class User (val name:String, @get:PreAuthorize("hasAuthority('user:read')") val email:String) +---- +====== + +You can proxy an instance of user in the following way: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Autowired +AuthorizationProxyFactory proxyFactory; + +@Test +void getEmailWhenProxiedThenAuthorizes() { + User user = new User("name", "email"); + assertThat(user.getEmail()).isNotNull(); + User securedUser = proxyFactory.proxy(user); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(securedUser::getEmail); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Autowired +var proxyFactory:AuthorizationProxyFactory? = null + +@Test +fun getEmailWhenProxiedThenAuthorizes() { + val user: User = User("name", "email") + assertThat(user.getEmail()).isNotNull() + val securedUser: User = proxyFactory.proxy(user) + assertThatExceptionOfType(AccessDeniedException::class.java).isThrownBy(securedUser::getEmail) +} +---- +====== + +=== Manual Construction + +You can also define your own instance if you need something different from the Spring Security default. + +For example, if you define an `AuthorizationProxyFactory` instance like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +import static org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize; + +// ... + +AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize()); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize + +// ... + +val proxyFactory: AuthorizationProxyFactory = AuthorizationProxyFactory(preAuthorize()) +---- +====== + +Then you can wrap any instance of `User` as follows: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Test +void getEmailWhenProxiedThenAuthorizes() { + AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize()); + User user = new User("name", "email"); + assertThat(user.getEmail()).isNotNull(); + User securedUser = proxyFactory.proxy(user); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(securedUser::getEmail); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Test +fun getEmailWhenProxiedThenAuthorizes() { + val proxyFactory: AuthorizationProxyFactory = AuthorizationProxyFactory(preAuthorize()) + val user: User = User("name", "email") + assertThat(user.getEmail()).isNotNull() + val securedUser: User = proxyFactory.proxy(user) + assertThatExceptionOfType(AccessDeniedException::class.java).isThrownBy(securedUser::getEmail) +} +---- +====== + +[NOTE] +==== +This feature does not yet support Spring AOT +==== + +=== Proxying Collections + +`AuthorizationProxyFactory` supports Java collections, streams, arrays, optionals, and iterators by proxying the element type and maps by proxying the value type. + +This means that when proxying a `List` of objects, the following also works: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Test +void getEmailWhenProxiedThenAuthorizes() { + AuthorizationProxyFactory proxyFactory = new AuthorizationProxyFactory(preAuthorize()); + List users = List.of(ada, albert, marie); + List securedUsers = proxyFactory.proxy(users); + securedUsers.forEach((securedUser) -> + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(securedUser::getEmail)); +} +---- +====== + +=== Proxying Classes + +In limited circumstances, it may be valuable to proxy a `Class` itself, and `AuthorizationProxyFactory` also supports this. +This is roughly the equivalent of calling `ProxyFactory#getProxyClass` in Spring Framework's support for creating proxies. + +One place where this is handy is when you need to construct the proxy class ahead-of-time, like with Spring AOT. + +=== Support for All Method Security Annotations + +`AuthorizationProxyFactory` supports whichever method security annotations are enabled in your application. +It is based off of whatever `AuthorizationAdvisor` classes are published as a bean. + +Since `@EnableMethodSecurity` publishes `@PreAuthorize`, `@PostAuthorize`, `@PreFilter`, and `@PostFilter` advisors by default, you will typically need to do nothing to activate the ability. + +[NOTE] +==== +SpEL expressions that use `returnObject` or `filterObject` sit behind the proxy and so have full access to the object. +==== + +[#custom_advice] +=== Custom Advice + +If you have security advice that you also want applied, you can publish your own `AuthorizationAdvisor` like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@EnableMethodSecurity +class SecurityConfig { + @Bean + static AuthorizationAdvisor myAuthorizationAdvisor() { + return new AuthorizationAdvisor(); + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@EnableMethodSecurity +internal class SecurityConfig { + @Bean + fun myAuthorizationAdvisor(): AuthorizationAdvisor { + return AuthorizationAdvisor() + } +] +---- +====== + +And Spring Security will add that advisor into the set of advice that `AuthorizationProxyFactory` adds when proxying an object. + +=== Working with Jackson + +One powerful use of this feature is to return a secured value from a controller like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@RestController +public class UserController { + @Autowired + AuthorizationProxyFactory proxyFactory; + + @GetMapping + User currentUser(@AuthenticationPrincipal User user) { + return this.proxyFactory.proxy(user); + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@RestController +class UserController { + @Autowired + var proxyFactory: AuthorizationProxyFactory? = null + + @GetMapping + fun currentUser(@AuthenticationPrincipal user:User?): User { + return proxyFactory.proxy(user) + } +} +---- +====== + +If you are using Jackson, though, this may result in a serialization error like the following: + +[source,bash] +==== +com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle +==== + +This is due to how Jackson works with CGLIB proxies. +To address this, add the following annotation to the top of the `User` class: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@JsonSerialize(as = User.class) +public class User { + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@JsonSerialize(`as` = User::class) +class User +---- +====== + +Finally, you will need to publish a <> to catch the `AccessDeniedException` thrown for each field, which you can do like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +public class AccessDeniedExceptionInterceptor implements AuthorizationAdvisor { + private final AuthorizationAdvisor advisor = AuthorizationManagerBeforeMethodInterceptor.preAuthorize(); + + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + try { + return invocation.proceed(); + } catch (AccessDeniedException ex) { + return null; + } + } + + @Override + public Pointcut getPointcut() { + return this.advisor.getPointcut(); + } + + @Override + public Advice getAdvice() { + return this; + } + + @Override + public int getOrder() { + return this.advisor.getOrder() - 1; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class AccessDeniedExceptionInterceptor: AuthorizationAdvisor { + var advisor: AuthorizationAdvisor = AuthorizationManagerBeforeMethodInterceptor.preAuthorize() + + @Throws(Throwable::class) + fun invoke(invocation: MethodInvocation): Any? { + return try { + invocation.proceed() + } catch (ex:AccessDeniedException) { + null + } + } + + val pointcut: Pointcut + get() = advisor.getPointcut() + + val advice: Advice + get() = this + + val order: Int + get() = advisor.getOrder() - 1 +} +---- +====== + +Then, you'll see a different JSON serialization based on the authorization level of the user. +If they don't have the `user:read` authority, then they'll see: + +[source,json] +---- +{ + "name" : "name", + "email" : null +} +---- + +And if they do have that authority, they'll see: + +[source,json] +---- +{ + "name" : "name", + "email" : "email" +} +---- + +[TIP] +==== +You can also add the Spring Boot property `spring.jackson.default-property-inclusion=non_null` to exclude the null value, if you also don't want to reveal the JSON key to an unauthorized user. +==== + [[migration-enableglobalmethodsecurity]] == Migrating from `@EnableGlobalMethodSecurity` diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 6f1756c0cc..dc7122d483 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -8,6 +8,10 @@ Below are the highlights of the release. - https://spring.io/blog/2024/01/19/spring-security-6-3-adds-passive-jdk-serialization-deserialization-for[blog post] - Added Passive JDK Serialization/Deserialization for Seamless Upgrades +== Authorization + +- https://github.com/spring-projects/spring-security/issues/14596[gh-14596] - xref:servlet/authorization/method-security.adoc[docs] - Add Programmatic Proxy Support for Method Security + == Configuration - https://github.com/spring-projects/spring-security/issues/6192[gh-6192] - xref:reactive/authentication/concurrent-sessions-control.adoc[(docs)] - Add Concurrent Sessions Control on WebFlux