diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/EnableReactiveMethodSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/EnableReactiveMethodSecurity.java new file mode 100644 index 0000000000..19996cd3dc --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/EnableReactiveMethodSecurity.java @@ -0,0 +1,71 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.springframework.security.config.annotation.method.configuration; + +import org.springframework.context.annotation.AdviceMode; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME) +@Target(value = { java.lang.annotation.ElementType.TYPE }) +@Documented +@Import({ ReactiveMethodSecuritySelector.class }) +@Configuration +public @interface EnableReactiveMethodSecurity { + /** + * Indicate whether subclass-based (CGLIB) proxies are to be created as opposed + * to standard Java interface-based proxies. The default is {@code false}. + * Applicable only if {@link #mode()} is set to {@link AdviceMode#PROXY}. + *

Note that setting this attribute to {@code true} will affect all + * Spring-managed beans requiring proxying, not just those marked with {@code @Cacheable}. + * For example, other beans marked with Spring's {@code @Transactional} annotation will + * be upgraded to subclass proxying at the same time. This approach has no negative + * impact in practice unless one is explicitly expecting one type of proxy vs another, + * e.g. in tests. + */ + boolean proxyTargetClass() default false; + + /** + * Indicate how security advice should be applied. The default is + * {@link AdviceMode#PROXY}. + * @see AdviceMode + * + * @return the {@link AdviceMode} to use + */ + AdviceMode mode() default AdviceMode.PROXY; + + /** + * Indicate the ordering of the execution of the security advisor when multiple + * advices are applied at a specific joinpoint. The default is + * {@link Ordered#LOWEST_PRECEDENCE}. + * + * @return the order the security advisor should be applied + */ + int order() default Ordered.LOWEST_PRECEDENCE; +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityConfiguration.java new file mode 100644 index 0000000000..5c4f1a5b38 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityConfiguration.java @@ -0,0 +1,85 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.springframework.security.config.annotation.method.configuration; + +import org.springframework.beans.factory.config.BeanDefinition; +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.type.AnnotationMetadata; +import org.springframework.security.access.expression.method.*; +import org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor; +import org.springframework.security.access.method.AbstractMethodSecurityMetadataSource; +import org.springframework.security.access.method.DelegatingMethodSecurityMetadataSource; +import org.springframework.security.access.method.PrePostAdviceMethodInterceptor; +import org.springframework.security.access.prepost.PrePostAnnotationSecurityMetadataSource; + +import java.util.Arrays; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Configuration +class ReactiveMethodSecurityConfiguration implements ImportAware { + private int advisorOrder; + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public MethodSecurityMetadataSourceAdvisor methodSecurityInterceptor(AbstractMethodSecurityMetadataSource source) throws Exception { + MethodSecurityMetadataSourceAdvisor advisor = new MethodSecurityMetadataSourceAdvisor( + "securityMethodInterceptor", source, "methodMetadataSource"); + advisor.setOrder(advisorOrder); + return advisor; + } + + @Bean + public DelegatingMethodSecurityMetadataSource methodMetadataSource() { + ExpressionBasedAnnotationAttributeFactory attributeFactory = new ExpressionBasedAnnotationAttributeFactory( + new DefaultMethodSecurityExpressionHandler()); + PrePostAnnotationSecurityMetadataSource prePostSource = new PrePostAnnotationSecurityMetadataSource( + attributeFactory); + return new DelegatingMethodSecurityMetadataSource(Arrays.asList(prePostSource)); + } + + @Bean + public PrePostAdviceMethodInterceptor securityMethodInterceptor(AbstractMethodSecurityMetadataSource source, MethodSecurityExpressionHandler handler) { + + ExpressionBasedPostInvocationAdvice postAdvice = new ExpressionBasedPostInvocationAdvice( + handler); + ExpressionBasedPreInvocationAdvice preAdvice = new ExpressionBasedPreInvocationAdvice(); + preAdvice.setExpressionHandler(handler); + + PrePostAdviceMethodInterceptor result = new PrePostAdviceMethodInterceptor(source); + result.setPostAdvice(postAdvice); + result.setPreAdvice(preAdvice); + return result; + } + + @Bean + public DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler() { + return new DefaultMethodSecurityExpressionHandler(); + } + + @Override + public void setImportMetadata(AnnotationMetadata importMetadata) { + this.advisorOrder = (int) importMetadata.getAnnotationAttributes(EnableReactiveMethodSecurity.class.getName()).get("order"); + } +} 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 new file mode 100644 index 0000000000..acb0888d59 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java @@ -0,0 +1,57 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.springframework.security.config.annotation.method.configuration; + +import org.springframework.cache.annotation.ProxyCachingConfiguration; +import org.springframework.context.annotation.AdviceMode; +import org.springframework.context.annotation.AdviceModeImportSelector; +import org.springframework.context.annotation.AutoProxyRegistrar; +import org.springframework.lang.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Rob Winch + * @since 5.0 + */ +class ReactiveMethodSecuritySelector extends + AdviceModeImportSelector { + + @Override + protected String[] selectImports(AdviceMode adviceMode) { + switch (adviceMode) { + case PROXY: + return getProxyImports(); + default: + throw new IllegalStateException("AdviceMode " + adviceMode + " is not supported"); + } + } + + /** + * Return the imports to use if the {@link AdviceMode} is set to {@link AdviceMode#PROXY}. + *

Take care of adding the necessary JSR-107 import if it is available. + */ + private String[] getProxyImports() { + List result = new ArrayList<>(); + result.add(AutoProxyRegistrar.class.getName()); + result.add(ReactiveMethodSecurityConfiguration.class.getName()); + return result.toArray(new String[result.size()]); + } +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/Authz.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/Authz.java new file mode 100644 index 0000000000..c596b3ca1a --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/Authz.java @@ -0,0 +1,38 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.springframework.security.config.annotation.method.configuration; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Component +public class Authz { + public boolean check(long id) { + return id % 2 == 0; + } + + public boolean check(Authentication authentication, String message) { + return message != null && + message.contains(authentication.getName()); + } +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/DelegatingReactiveMessageService.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/DelegatingReactiveMessageService.java new file mode 100644 index 0000000000..8298012d58 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/DelegatingReactiveMessageService.java @@ -0,0 +1,131 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://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.reactivestreams.Publisher; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PreAuthorize; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class DelegatingReactiveMessageService implements ReactiveMessageService { + private final ReactiveMessageService delegate; + + public DelegatingReactiveMessageService(ReactiveMessageService delegate) { + this.delegate = delegate; + } + + @Override + public Mono monoFindById(long id) { + return delegate.monoFindById(id); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + public Mono monoPreAuthorizeHasRoleFindById( + long id) { + return delegate.monoPreAuthorizeHasRoleFindById(id); + } + + @Override + @PostAuthorize("returnObject?.contains(authentication?.name)") + public Mono monoPostAuthorizeFindById( + long id) { + return delegate.monoPostAuthorizeFindById(id); + } + + @Override + @PreAuthorize("@authz.check(#id)") + public Mono monoPreAuthorizeBeanFindById( + long id) { + return delegate.monoPreAuthorizeBeanFindById(id); + } + + @Override + @PostAuthorize("@authz.check(authentication, returnObject)") + public Mono monoPostAuthorizeBeanFindById( + long id) { + return delegate.monoPostAuthorizeBeanFindById(id); + } + + @Override + public Flux fluxFindById(long id) { + return delegate.fluxFindById(id); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + public Flux fluxPreAuthorizeHasRoleFindById( + long id) { + return delegate.fluxPreAuthorizeHasRoleFindById(id); + } + + @Override + @PostAuthorize("returnObject?.contains(authentication?.name)") + public Flux fluxPostAuthorizeFindById( + long id) { + return delegate.fluxPostAuthorizeFindById(id); + } + + @Override + @PreAuthorize("@authz.check(#id)") + public Flux fluxPreAuthorizeBeanFindById( + long id) { + return delegate.fluxPreAuthorizeBeanFindById(id); + } + + @Override + @PostAuthorize("@authz.check(authentication, returnObject)") + public Flux fluxPostAuthorizeBeanFindById( + long id) { + return delegate.fluxPostAuthorizeBeanFindById(id); + } + + @Override + public Publisher publisherFindById(long id) { + return delegate.publisherFindById(id); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + public Publisher publisherPreAuthorizeHasRoleFindById( + long id) { + return delegate.publisherPreAuthorizeHasRoleFindById(id); + } + + @Override + @PostAuthorize("returnObject?.contains(authentication?.name)") + public Publisher publisherPostAuthorizeFindById( + long id) { + return delegate.publisherPostAuthorizeFindById(id); + } + + @Override + @PreAuthorize("@authz.check(#id)") + public Publisher publisherPreAuthorizeBeanFindById( + long id) { + return delegate.publisherPreAuthorizeBeanFindById(id); + } + + @Override + @PostAuthorize("@authz.check(authentication, returnObject)") + public Publisher publisherPostAuthorizeBeanFindById( + long id) { + return delegate.publisherPostAuthorizeBeanFindById(id); + } +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/EnableReactiveMethodSecurityTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/EnableReactiveMethodSecurityTests.java new file mode 100644 index 0000000000..ba611b6ab1 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/EnableReactiveMethodSecurityTests.java @@ -0,0 +1,593 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://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.assertj.core.api.AssertionsForClassTypes; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.test.publisher.TestPublisher; +import reactor.util.context.Context; + +import java.util.function.Function; + +import static org.mockito.Mockito.*; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(SpringRunner.class) +@ContextConfiguration +public class EnableReactiveMethodSecurityTests { + @Autowired ReactiveMessageService messageService; + ReactiveMessageService delegate; + TestPublisher result = TestPublisher.create(); + + Function withAdmin = context -> context.put(Authentication.class, Mono + .just(new TestingAuthenticationToken("admin","password","ROLE_USER", "ROLE_ADMIN"))); + Function withUser = context -> context.put(Authentication.class, Mono + .just(new TestingAuthenticationToken("user","password","ROLE_USER"))); + + @After + public void cleanup() { + reset(delegate); + } + + @Autowired + public void setConfig(Config config) { + this.delegate = config.delegate; + } + + @Test + public void monoWhenPermitAllThenAopDoesNotSubscribe() { + when(this.delegate.monoFindById(1L)).thenReturn(Mono.from(result)); + + this.delegate.monoFindById(1L); + + result.assertNoSubscribers(); + } + + @Test + public void monoWhenPermitAllThenSuccess() { + when(this.delegate.monoFindById(1L)).thenReturn(Mono.just("success")); + + StepVerifier.create(this.delegate.monoFindById(1L)) + .expectNext("success") + .verifyComplete(); + } + + @Test + public void monoPreAuthorizeHasRoleWhenGrantedThenSuccess() { + when(this.delegate.monoPreAuthorizeHasRoleFindById(1L)).thenReturn(Mono.just("result")); + + Mono findById = this.messageService.monoPreAuthorizeHasRoleFindById(1L) + .contextStart(withAdmin); + StepVerifier + .create(findById) + .expectNext("result") + .verifyComplete(); + } + + @Test + public void monoPreAuthorizeHasRoleWhenNoAuthenticationThenDenied() { + when(this.delegate.monoPreAuthorizeHasRoleFindById(1L)).thenReturn(Mono.from(result)); + + Mono findById = this.messageService.monoPreAuthorizeHasRoleFindById(1L); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void monoPreAuthorizeHasRoleWhenNotAuthorizedThenDenied() { + when(this.delegate.monoPreAuthorizeHasRoleFindById(1L)).thenReturn(Mono.from(result)); + + Mono findById = this.messageService.monoPreAuthorizeHasRoleFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void monoPreAuthorizeBeanWhenGrantedThenSuccess() { + when(this.delegate.monoPreAuthorizeBeanFindById(2L)).thenReturn(Mono.just("result")); + + Mono findById = this.messageService.monoPreAuthorizeBeanFindById(2L) + .contextStart(withAdmin); + StepVerifier + .create(findById) + .expectNext("result") + .verifyComplete(); + } + + @Test + public void monoPreAuthorizeBeanWhenNotAuthenticatedAndGrantedThenSuccess() { + when(this.delegate.monoPreAuthorizeBeanFindById(2L)).thenReturn(Mono.just("result")); + + Mono findById = this.messageService.monoPreAuthorizeBeanFindById(2L); + StepVerifier + .create(findById) + .expectNext("result") + .verifyComplete(); + } + + @Test + public void monoPreAuthorizeBeanWhenNoAuthenticationThenDenied() { + when(this.delegate.monoPreAuthorizeBeanFindById(1L)).thenReturn(Mono.from(result)); + + Mono findById = this.messageService.monoPreAuthorizeBeanFindById(1L); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void monoPreAuthorizeBeanWhenNotAuthorizedThenDenied() { + when(this.delegate.monoPreAuthorizeBeanFindById(1L)).thenReturn(Mono.from(result)); + + Mono findById = this.messageService.monoPreAuthorizeBeanFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void monoPostAuthorizeWhenAuthorizedThenSuccess() { + when(this.delegate.monoPostAuthorizeFindById(1L)).thenReturn(Mono.just("user")); + + Mono findById = this.messageService.monoPostAuthorizeFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectNext("user") + .verifyComplete(); + } + + @Test + public void monoPostAuthorizeWhenNotAuthorizedThenDenied() { + when(this.delegate.monoPostAuthorizeBeanFindById(1L)).thenReturn(Mono.just("not-authorized")); + + Mono findById = this.messageService.monoPostAuthorizeBeanFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + } + + @Test + public void monoPostAuthorizeWhenBeanAndAuthorizedThenSuccess() { + when(this.delegate.monoPostAuthorizeBeanFindById(2L)).thenReturn(Mono.just("user")); + + Mono findById = this.messageService.monoPostAuthorizeBeanFindById(2L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectNext("user") + .verifyComplete(); + } + + @Test + public void monoPostAuthorizeWhenBeanAndNotAuthenticatedAndAuthorizedThenSuccess() { + when(this.delegate.monoPostAuthorizeBeanFindById(2L)).thenReturn(Mono.just("anonymous")); + + Mono findById = this.messageService.monoPostAuthorizeBeanFindById(2L); + StepVerifier + .create(findById) + .expectNext("anonymous") + .verifyComplete(); + } + + @Test + public void monoPostAuthorizeWhenBeanAndNotAuthorizedThenDenied() { + when(this.delegate.monoPostAuthorizeBeanFindById(1L)).thenReturn(Mono.just("not-authorized")); + + Mono findById = this.messageService.monoPostAuthorizeBeanFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + } + + // Flux tests + + @Test + public void fluxWhenPermitAllThenAopDoesNotSubscribe() { + when(this.delegate.fluxFindById(1L)).thenReturn(Flux.from(result)); + + this.delegate.fluxFindById(1L); + + result.assertNoSubscribers(); + } + + @Test + public void fluxWhenPermitAllThenSuccess() { + when(this.delegate.fluxFindById(1L)).thenReturn(Flux.just("success")); + + StepVerifier.create(this.delegate.fluxFindById(1L)) + .expectNext("success") + .verifyComplete(); + } + + @Test + public void fluxPreAuthorizeHasRoleWhenGrantedThenSuccess() { + when(this.delegate.fluxPreAuthorizeHasRoleFindById(1L)).thenReturn(Flux.just("result")); + + Flux findById = this.messageService.fluxPreAuthorizeHasRoleFindById(1L) + .contextStart(withAdmin); + StepVerifier + .create(findById) + .consumeNextWith( s -> AssertionsForClassTypes.assertThat(s).isEqualTo("result")) + .verifyComplete(); + } + + @Test + public void fluxPreAuthorizeHasRoleWhenNoAuthenticationThenDenied() { + when(this.delegate.fluxPreAuthorizeHasRoleFindById(1L)).thenReturn(Flux.from(result)); + + Flux findById = this.messageService.fluxPreAuthorizeHasRoleFindById(1L); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void fluxPreAuthorizeHasRoleWhenNotAuthorizedThenDenied() { + when(this.delegate.fluxPreAuthorizeHasRoleFindById(1L)).thenReturn(Flux.from(result)); + + Flux findById = this.messageService.fluxPreAuthorizeHasRoleFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void fluxPreAuthorizeBeanWhenGrantedThenSuccess() { + when(this.delegate.fluxPreAuthorizeBeanFindById(2L)).thenReturn(Flux.just("result")); + + Flux findById = this.messageService.fluxPreAuthorizeBeanFindById(2L) + .contextStart(withAdmin); + StepVerifier + .create(findById) + .expectNext("result") + .verifyComplete(); + } + + @Test + public void fluxPreAuthorizeBeanWhenNotAuthenticatedAndGrantedThenSuccess() { + when(this.delegate.fluxPreAuthorizeBeanFindById(2L)).thenReturn(Flux.just("result")); + + Flux findById = this.messageService.fluxPreAuthorizeBeanFindById(2L); + StepVerifier + .create(findById) + .expectNext("result") + .verifyComplete(); + } + + @Test + public void fluxPreAuthorizeBeanWhenNoAuthenticationThenDenied() { + when(this.delegate.fluxPreAuthorizeBeanFindById(1L)).thenReturn(Flux.from(result)); + + Flux findById = this.messageService.fluxPreAuthorizeBeanFindById(1L); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void fluxPreAuthorizeBeanWhenNotAuthorizedThenDenied() { + when(this.delegate.fluxPreAuthorizeBeanFindById(1L)).thenReturn(Flux.from(result)); + + Flux findById = this.messageService.fluxPreAuthorizeBeanFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void fluxPostAuthorizeWhenAuthorizedThenSuccess() { + when(this.delegate.fluxPostAuthorizeFindById(1L)).thenReturn(Flux.just("user")); + + Flux findById = this.messageService.fluxPostAuthorizeFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectNext("user") + .verifyComplete(); + } + + @Test + public void fluxPostAuthorizeWhenNotAuthorizedThenDenied() { + when(this.delegate.fluxPostAuthorizeBeanFindById(1L)).thenReturn(Flux.just("not-authorized")); + + Flux findById = this.messageService.fluxPostAuthorizeBeanFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + } + + @Test + public void fluxPostAuthorizeWhenBeanAndAuthorizedThenSuccess() { + when(this.delegate.fluxPostAuthorizeBeanFindById(2L)).thenReturn(Flux.just("user")); + + Flux findById = this.messageService.fluxPostAuthorizeBeanFindById(2L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectNext("user") + .verifyComplete(); + } + + @Test + public void fluxPostAuthorizeWhenBeanAndNotAuthenticatedAndAuthorizedThenSuccess() { + when(this.delegate.fluxPostAuthorizeBeanFindById(2L)).thenReturn(Flux.just("anonymous")); + + Flux findById = this.messageService.fluxPostAuthorizeBeanFindById(2L); + StepVerifier + .create(findById) + .expectNext("anonymous") + .verifyComplete(); + } + + @Test + public void fluxPostAuthorizeWhenBeanAndNotAuthorizedThenDenied() { + when(this.delegate.fluxPostAuthorizeBeanFindById(1L)).thenReturn(Flux.just("not-authorized")); + + Flux findById = this.messageService.fluxPostAuthorizeBeanFindById(1L) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + } + + // Publisher tests + + @Test + public void publisherWhenPermitAllThenAopDoesNotSubscribe() { + when(this.delegate.publisherFindById(1L)).thenReturn(result); + + this.delegate.publisherFindById(1L); + + result.assertNoSubscribers(); + } + + @Test + public void publisherWhenPermitAllThenSuccess() { + when(this.delegate.publisherFindById(1L)).thenReturn(publisherJust("success")); + + StepVerifier.create(this.delegate.publisherFindById(1L)) + .expectNext("success") + .verifyComplete(); + } + + @Test + public void publisherPreAuthorizeHasRoleWhenGrantedThenSuccess() { + when(this.delegate.publisherPreAuthorizeHasRoleFindById(1L)).thenReturn(publisherJust("result")); + + Publisher findById = Flux.from(this.messageService.publisherPreAuthorizeHasRoleFindById(1L)) + .contextStart(withAdmin); + StepVerifier + .create(findById) + .consumeNextWith( s -> AssertionsForClassTypes.assertThat(s).isEqualTo("result")) + .verifyComplete(); + } + + @Test + public void publisherPreAuthorizeHasRoleWhenNoAuthenticationThenDenied() { + when(this.delegate.publisherPreAuthorizeHasRoleFindById(1L)).thenReturn(result); + + Publisher findById = this.messageService.publisherPreAuthorizeHasRoleFindById(1L); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void publisherPreAuthorizeHasRoleWhenNotAuthorizedThenDenied() { + when(this.delegate.publisherPreAuthorizeHasRoleFindById(1L)).thenReturn(result); + + Publisher findById = Flux.from(this.messageService.publisherPreAuthorizeHasRoleFindById(1L)) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void publisherPreAuthorizeBeanWhenGrantedThenSuccess() { + when(this.delegate.publisherPreAuthorizeBeanFindById(2L)).thenReturn(publisherJust("result")); + + Publisher findById = Flux.from(this.messageService.publisherPreAuthorizeBeanFindById(2L)) + .contextStart(withAdmin); + StepVerifier + .create(findById) + .expectNext("result") + .verifyComplete(); + } + + @Test + public void publisherPreAuthorizeBeanWhenNotAuthenticatedAndGrantedThenSuccess() { + when(this.delegate.publisherPreAuthorizeBeanFindById(2L)).thenReturn(publisherJust("result")); + + Publisher findById = this.messageService.publisherPreAuthorizeBeanFindById(2L); + StepVerifier + .create(findById) + .expectNext("result") + .verifyComplete(); + } + + @Test + public void publisherPreAuthorizeBeanWhenNoAuthenticationThenDenied() { + when(this.delegate.publisherPreAuthorizeBeanFindById(1L)).thenReturn(result); + + Publisher findById = this.messageService.publisherPreAuthorizeBeanFindById(1L); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void publisherPreAuthorizeBeanWhenNotAuthorizedThenDenied() { + when(this.delegate.publisherPreAuthorizeBeanFindById(1L)).thenReturn(result); + + Publisher findById = Flux.from(this.messageService.publisherPreAuthorizeBeanFindById(1L)) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + + result.assertNoSubscribers(); + } + + @Test + public void publisherPostAuthorizeWhenAuthorizedThenSuccess() { + when(this.delegate.publisherPostAuthorizeFindById(1L)).thenReturn(publisherJust("user")); + + Publisher findById = Flux.from(this.messageService.publisherPostAuthorizeFindById(1L)) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectNext("user") + .verifyComplete(); + } + + @Test + public void publisherPostAuthorizeWhenNotAuthorizedThenDenied() { + when(this.delegate.publisherPostAuthorizeBeanFindById(1L)).thenReturn(publisherJust("not-authorized")); + + Publisher findById = Flux.from(this.messageService.publisherPostAuthorizeBeanFindById(1L)) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + } + + @Test + public void publisherPostAuthorizeWhenBeanAndAuthorizedThenSuccess() { + when(this.delegate.publisherPostAuthorizeBeanFindById(2L)).thenReturn(publisherJust("user")); + + Publisher findById = Flux.from(this.messageService.publisherPostAuthorizeBeanFindById(2L)) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectNext("user") + .verifyComplete(); + } + + @Test + public void publisherPostAuthorizeWhenBeanAndNotAuthenticatedAndAuthorizedThenSuccess() { + when(this.delegate.publisherPostAuthorizeBeanFindById(2L)).thenReturn(publisherJust("anonymous")); + + Publisher findById = this.messageService.publisherPostAuthorizeBeanFindById(2L); + StepVerifier + .create(findById) + .expectNext("anonymous") + .verifyComplete(); + } + + @Test + public void publisherPostAuthorizeWhenBeanAndNotAuthorizedThenDenied() { + when(this.delegate.publisherPostAuthorizeBeanFindById(1L)).thenReturn(publisherJust("not-authorized")); + + Publisher findById = Flux.from(this.messageService.publisherPostAuthorizeBeanFindById(1L)) + .contextStart(withUser); + StepVerifier + .create(findById) + .expectError(AccessDeniedException.class) + .verify(); + } + + static Publisher publisher(Flux flux) { + return subscriber -> flux.subscribe(subscriber); + } + + static Publisher publisherJust(T... data) { + return publisher(Flux.just(data)); + } + + @EnableReactiveMethodSecurity + static class Config { + ReactiveMessageService delegate = mock(ReactiveMessageService.class); + + @Bean + public DelegatingReactiveMessageService defaultMessageService() { + return new DelegatingReactiveMessageService(delegate); + } + + @Bean + public Authz authz() { + return new Authz(); + } + } +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMessageService.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMessageService.java new file mode 100644 index 0000000000..979b2f48a8 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMessageService.java @@ -0,0 +1,42 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://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.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface ReactiveMessageService { + Mono monoFindById(long id); + Mono monoPreAuthorizeHasRoleFindById(long id); + Mono monoPostAuthorizeFindById(long id); + Mono monoPreAuthorizeBeanFindById(long id); + Mono monoPostAuthorizeBeanFindById(long id); + + Flux fluxFindById(long id); + Flux fluxPreAuthorizeHasRoleFindById(long id); + Flux fluxPostAuthorizeFindById(long id); + Flux fluxPreAuthorizeBeanFindById(long id); + Flux fluxPostAuthorizeBeanFindById(long id); + + Publisher publisherFindById(long id); + Publisher publisherPreAuthorizeHasRoleFindById(long id); + Publisher publisherPostAuthorizeFindById(long id); + Publisher publisherPreAuthorizeBeanFindById(long id); + Publisher publisherPostAuthorizeBeanFindById(long id); +} diff --git a/core/src/main/java/org/springframework/security/access/method/PrePostAdviceMethodInterceptor.java b/core/src/main/java/org/springframework/security/access/method/PrePostAdviceMethodInterceptor.java new file mode 100644 index 0000000000..32e64910b2 --- /dev/null +++ b/core/src/main/java/org/springframework/security/access/method/PrePostAdviceMethodInterceptor.java @@ -0,0 +1,147 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://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.access.method; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.reactivestreams.Publisher; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.ConfigAttribute; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.ExpressionBasedPostInvocationAdvice; +import org.springframework.security.access.expression.method.ExpressionBasedPreInvocationAdvice; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.access.method.MethodSecurityMetadataSource; +import org.springframework.security.access.prepost.PostInvocationAttribute; +import org.springframework.security.access.prepost.PostInvocationAuthorizationAdvice; +import org.springframework.security.access.prepost.PreInvocationAttribute; +import org.springframework.security.access.prepost.PreInvocationAuthorizationAdvice; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.util.Assert; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import java.lang.reflect.Method; +import java.util.Collection; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class PrePostAdviceMethodInterceptor implements MethodInterceptor { + private Authentication anonymous = new AnonymousAuthenticationToken("key", "anonymous", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + + private final MethodSecurityMetadataSource attributeSource; + + private PostInvocationAuthorizationAdvice postAdvice; + + private PreInvocationAuthorizationAdvice preAdvice; + + public PrePostAdviceMethodInterceptor(MethodSecurityMetadataSource attributeSource) { + this.attributeSource = attributeSource; + + MethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); + this.postAdvice = new ExpressionBasedPostInvocationAdvice(handler); + this.preAdvice = new ExpressionBasedPreInvocationAdvice(); + } + + public void setPostAdvice(PostInvocationAuthorizationAdvice postAdvice) { + Assert.notNull(postAdvice, "postAdvice cannot be null"); + this.postAdvice = postAdvice; + } + + public void setPreAdvice(PreInvocationAuthorizationAdvice preAdvice) { + Assert.notNull(preAdvice, "preAdvice cannot be null"); + this.preAdvice = preAdvice; + } + + @Override + public Object invoke(final MethodInvocation invocation) + throws Throwable { + Method method = invocation.getMethod(); + Class returnType = method.getReturnType(); + Class targetClass = invocation.getThis().getClass(); + Collection attributes = this.attributeSource + .getAttributes(method, targetClass); + + PreInvocationAttribute preAttr = findPreInvocationAttribute(attributes); + Mono toInvoke = Mono.currentContext() + .defaultIfEmpty(Context.empty()) + .flatMap( cxt -> cxt.getOrDefault(Authentication.class, Mono.just(anonymous))) + .filter( auth -> this.preAdvice.before(auth, invocation, preAttr)) + .switchIfEmpty(Mono.error(new AccessDeniedException("Denied"))); + + + PostInvocationAttribute attr = findPostInvocationAttribute(attributes); + + if(Mono.class.isAssignableFrom(returnType)) { + return toInvoke + .flatMap( auth -> this.>proceed(invocation) + .map( r -> attr == null ? r : this.postAdvice.after(auth, invocation, attr, r)) + ); + } + + if(Flux.class.isAssignableFrom(returnType)) { + return toInvoke + .flatMapMany( auth -> this.>proceed(invocation) + .map( r -> attr == null ? r : this.postAdvice.after(auth, invocation, attr, r)) + ); + } + + return toInvoke + .flatMapMany( auth -> Flux.from(this.>proceed(invocation)) + .map( r -> attr == null ? r : this.postAdvice.after(auth, invocation, attr, r)) + ); + } + + private> T proceed(final MethodInvocation invocation) { + try { + return (T) invocation.proceed(); + } catch(Throwable throwable) { + throw Exceptions.propagate(throwable); + } + } + + private static PostInvocationAttribute findPostInvocationAttribute( + Collection config) { + for (ConfigAttribute attribute : config) { + if (attribute instanceof PostInvocationAttribute) { + return (PostInvocationAttribute) attribute; + } + } + + return null; + } + + private PreInvocationAttribute findPreInvocationAttribute( + Collection config) { + for (ConfigAttribute attribute : config) { + if (attribute instanceof PreInvocationAttribute) { + return (PreInvocationAttribute) attribute; + } + } + + return null; + } +}