diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java index 4f53005f7c8..640615e5fa6 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java @@ -28,6 +28,7 @@ import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.authorization.method.AuthorizationManagerAfterReactiveMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeReactiveMethodInterceptor; @@ -57,10 +58,14 @@ static PreFilterAuthorizationReactiveMethodInterceptor preFilterInterceptor( @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) static AuthorizationManagerBeforeReactiveMethodInterceptor preAuthorizeInterceptor( - MethodSecurityExpressionHandler expressionHandler, ObjectProvider registryProvider) { + MethodSecurityExpressionHandler expressionHandler, ObjectProvider registryProvider, + ObjectProvider eventPublisherProvider) { ReactiveAuthorizationManager authorizationManager = manager( new PreAuthorizeReactiveAuthorizationManager(expressionHandler), registryProvider); - return AuthorizationManagerBeforeReactiveMethodInterceptor.preAuthorize(authorizationManager); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = AuthorizationManagerBeforeReactiveMethodInterceptor + .preAuthorize(authorizationManager); + eventPublisherProvider.ifAvailable(interceptor::setAuthorizationEventPublisher); + return interceptor; } @Bean @@ -73,10 +78,14 @@ static PostFilterAuthorizationReactiveMethodInterceptor postFilterInterceptor( @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) static AuthorizationManagerAfterReactiveMethodInterceptor postAuthorizeInterceptor( - MethodSecurityExpressionHandler expressionHandler, ObjectProvider registryProvider) { + MethodSecurityExpressionHandler expressionHandler, ObjectProvider registryProvider, + ObjectProvider eventPublisherProvider) { ReactiveAuthorizationManager authorizationManager = manager( new PostAuthorizeReactiveAuthorizationManager(expressionHandler), registryProvider); - return AuthorizationManagerAfterReactiveMethodInterceptor.postAuthorize(authorizationManager); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = AuthorizationManagerAfterReactiveMethodInterceptor + .postAuthorize(authorizationManager); + eventPublisherProvider.ifAvailable(interceptor::setAuthorizationEventPublisher); + return interceptor; } @Bean diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 6bd9570cf9e..fbd4187970e 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -56,7 +56,9 @@ import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ObservationReactiveAuthorizationManager; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.authorization.SpringReactiveAuthorizationEventPublisher; import org.springframework.security.config.Customizer; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -1794,6 +1796,8 @@ public class AuthorizeExchangeSpec extends AbstractServerWebExchangeMatcherRegis private PathPatternParser pathPatternParser; + private ReactiveAuthorizationEventPublisher authorizationEventPublisher; + /** * Allows method chaining to continue configuring the {@link ServerHttpSecurity} * @return the {@link ServerHttpSecurity} to continue configuring @@ -1851,9 +1855,23 @@ protected void configure(ServerHttpSecurity http) { manager = new ObservationReactiveAuthorizationManager<>(registry, manager); } AuthorizationWebFilter result = new AuthorizationWebFilter(manager); + ReactiveAuthorizationEventPublisher authorizationEventPublisher = getAuthorizationEventPublisher(http); + if (authorizationEventPublisher != null) { + result.setAuthorizationEventPublisher(authorizationEventPublisher); + } http.addFilterAt(result, SecurityWebFiltersOrder.AUTHORIZATION); } + private ReactiveAuthorizationEventPublisher getAuthorizationEventPublisher(ServerHttpSecurity http) { + if (this.authorizationEventPublisher == null) { + this.authorizationEventPublisher = getBeanOrNull(ReactiveAuthorizationEventPublisher.class); + } + if (this.authorizationEventPublisher == null && http.context != null) { + this.authorizationEventPublisher = new SpringReactiveAuthorizationEventPublisher(http.context); + } + return this.authorizationEventPublisher; + } + /** * Configures the access for a particular set of exchanges. */ diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfigurationTests.java new file mode 100644 index 00000000000..a7e2cc1eca5 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfigurationTests.java @@ -0,0 +1,269 @@ +/* + * Copyright 2002-2023 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.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; +import org.springframework.security.authorization.event.ReactiveAuthorizationDeniedEvent; +import org.springframework.security.authorization.event.ReactiveAuthorizationEvent; +import org.springframework.security.authorization.event.ReactiveAuthorizationGrantedEvent; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.test.context.support.ReactorContextTestExecutionListener; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener; +import org.springframework.stereotype.Component; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@TestExecutionListeners( + listeners = { WithSecurityContextTestExecutionListener.class, ReactorContextTestExecutionListener.class }) +public class ReactiveAuthorizationManagerMethodSecurityConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + ReactiveMessageService messageService; + + @Autowired + ReactiveAuthorizationEventPublisher eventPublisher; + + AuthenticationTrustResolverImpl trustResolver = new AuthenticationTrustResolverImpl(); + + @Test + void preAuthorizeMonoWhenDeniedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.monoPreAuthorizeHasRoleFindById(1)) + .expectError(AccessDeniedException.class) + .verify(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isFalse(); + StepVerifier.create(event.getAuthentication()).assertNext(this.trustResolver::isAnonymous).verifyComplete(); + }); + } + + @Test + @WithMockUser(roles = "ADMIN") + void preAuthorizeMonoWhenGrantedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.monoPreAuthorizeHasRoleFindById(1)).verifyComplete(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isTrue(); + StepVerifier.create(event.getAuthentication()) + .assertNext((auth) -> assertThat(auth.getAuthorities()).extracting(GrantedAuthority::getAuthority) + .contains("ROLE_ADMIN")) + .verifyComplete(); + }); + } + + @Test + void preAuthorizeFluxWhenDeniedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.fluxPreAuthorizeHasRoleFindById(1)) + .expectError(AccessDeniedException.class) + .verify(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isFalse(); + StepVerifier.create(event.getAuthentication()).assertNext(this.trustResolver::isAnonymous).verifyComplete(); + }); + } + + @Test + @WithMockUser(roles = "ADMIN") + void preAuthorizeFluxWhenGrantedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.fluxPreAuthorizeHasRoleFindById(1)).verifyComplete(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isTrue(); + StepVerifier.create(event.getAuthentication()) + .assertNext((auth) -> assertThat(auth.getAuthorities()).extracting(GrantedAuthority::getAuthority) + .contains("ROLE_ADMIN")) + .verifyComplete(); + }); + } + + @Test + void postAuthorizeMonoWhenDeniedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.monoPostAuthorizeFindById(1)) + .expectError(AccessDeniedException.class) + .verify(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isFalse(); + StepVerifier.create(event.getAuthentication()).assertNext(this.trustResolver::isAnonymous).verifyComplete(); + }); + } + + @Test + @WithMockUser(roles = "ADMIN") + void postAuthorizeMonoWhenGrantedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.monoPostAuthorizeFindById(1)).expectNext("user").verifyComplete(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isTrue(); + StepVerifier.create(event.getAuthentication()) + .assertNext((auth) -> assertThat(auth.getAuthorities()).extracting(GrantedAuthority::getAuthority) + .contains("ROLE_ADMIN")) + .verifyComplete(); + }); + } + + @Test + @WithMockUser(username = "notuser") + void postAuthorizeFluxWhenDeniedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.fluxPostAuthorizeFindById(1)) + .expectError(AccessDeniedException.class) + .verify(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isFalse(); + StepVerifier.create(event.getAuthentication()).assertNext(this.trustResolver::isAnonymous).verifyComplete(); + }); + } + + @Test + @WithMockUser + void postAuthorizeFluxWhenGrantedThenPublishEvent() { + this.spring.register(Config.class, AuthorizationEventPublisherConfig.class).autowire(); + StepVerifier.create(this.messageService.fluxPostAuthorizeFindById(1)).expectNext("user").verifyComplete(); + assertEvents((event) -> { + assertThat(event).isNotNull(); + assertThat(event.getAuthorizationDecision().isGranted()).isTrue(); + StepVerifier.create(event.getAuthentication()).expectNextCount(1).verifyComplete(); + }); + } + + private void assertEvents(Consumer assertConsumer) { + ReactiveAuthorizationEvent event = ImperativeListener.getEvent(); + ReactiveAuthorizationEvent reactiveEvent = ReactiveListener.getEvent(); + assertConsumer.accept(event); + assertConsumer.accept(reactiveEvent); + } + + @Configuration + @EnableReactiveMethodSecurity + static class Config { + + @Bean + DelegatingReactiveMessageService defaultMessageService() { + return new DelegatingReactiveMessageService(new StubReactiveMessageService()); + } + + @Bean + Authz authz() { + return new Authz(); + } + + } + + @Configuration(proxyBeanMethods = false) + @Import({ ImperativeListener.class, ReactiveListener.class }) + static class AuthorizationEventPublisherConfig { + + @Bean + ReactiveAuthorizationEventPublisher authorizationEventPublisher(ApplicationContext eventPublisher) { + return new ReactiveAuthorizationEventPublisher() { + @Override + public void publishAuthorizationEvent(Mono authentication, T object, + AuthorizationDecision decision) { + ReactiveAuthorizationEvent event; + if (decision.isGranted()) { + event = new ReactiveAuthorizationGrantedEvent<>(authentication, object, decision); + } + else { + event = new ReactiveAuthorizationDeniedEvent<>(authentication, object, decision); + } + eventPublisher.publishEvent(event); + } + }; + } + + } + + @Component + public static class ImperativeListener { + + static BlockingQueue events = new ArrayBlockingQueue<>(10); + + @EventListener + public void onEvent(ReactiveAuthorizationEvent event) { + events.add(event); + } + + public static T getEvent() { + try { + return (T) events.poll(1, TimeUnit.SECONDS); + } + catch (InterruptedException ex) { + return null; + } + } + + } + + @Component + public static class ReactiveListener { + + static BlockingQueue events = new ArrayBlockingQueue<>(10); + + @EventListener + public Mono onEvent(ReactiveAuthorizationEvent event) { + return event.getAuthentication().doOnNext((authentication) -> events.add(event)).then(); + } + + public static T getEvent() { + try { + return (T) events.poll(1, TimeUnit.SECONDS); + } + catch (InterruptedException ex) { + return null; + } + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/StubReactiveMessageService.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/StubReactiveMessageService.java new file mode 100644 index 00000000000..0ae0936fccb --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/StubReactiveMessageService.java @@ -0,0 +1,120 @@ +/* + * Copyright 2002-2023 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.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class StubReactiveMessageService implements ReactiveMessageService { + + @Override + public String notPublisherPreAuthorizeFindById(long id) { + return null; + } + + @Override + public Mono monoFindById(long id) { + return Mono.empty(); + } + + @Override + public Mono monoPreAuthorizeHasRoleFindById(long id) { + return Mono.empty(); + } + + @Override + public Mono monoPostAuthorizeFindById(long id) { + return Mono.just("user"); + } + + @Override + public Mono monoPreAuthorizeBeanFindById(long id) { + return Mono.empty(); + } + + @Override + public Mono monoPreAuthorizeBeanFindByIdReactiveExpression(long id) { + return Mono.empty(); + } + + @Override + public Mono monoPostAuthorizeBeanFindById(long id) { + return Mono.empty(); + } + + @Override + public Flux fluxFindById(long id) { + return Flux.empty(); + } + + @Override + public Flux fluxPreAuthorizeHasRoleFindById(long id) { + return Flux.empty(); + } + + @Override + public Flux fluxPostAuthorizeFindById(long id) { + return Flux.just("user"); + } + + @Override + public Flux fluxPreAuthorizeBeanFindById(long id) { + return Flux.empty(); + } + + @Override + public Flux fluxPostAuthorizeBeanFindById(long id) { + return Flux.empty(); + } + + @Override + public Flux fluxManyAnnotations(Flux flux) { + return Flux.empty(); + } + + @Override + public Flux fluxPostFilter(Flux flux) { + return Flux.empty(); + } + + @Override + public Publisher publisherFindById(long id) { + return Flux.empty(); + } + + @Override + public Publisher publisherPreAuthorizeHasRoleFindById(long id) { + return Flux.empty(); + } + + @Override + public Publisher publisherPostAuthorizeFindById(long id) { + return Flux.empty(); + } + + @Override + public Publisher publisherPreAuthorizeBeanFindById(long id) { + return Flux.empty(); + } + + @Override + public Publisher publisherPostAuthorizeBeanFindById(long id) { + return Flux.empty(); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationEventPublisher.java b/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationEventPublisher.java new file mode 100644 index 00000000000..b6972ca98d4 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationEventPublisher.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2023 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 reactor.core.publisher.Mono; + +import org.springframework.security.authorization.event.ReactiveAuthorizationDeniedEvent; +import org.springframework.security.authorization.event.ReactiveAuthorizationGrantedEvent; +import org.springframework.security.core.Authentication; + +/** + * A contract for publishing authorization events in a reactive application + * + * @author Marcus da Coregio + * @since 6.3 + * @see ReactiveAuthorizationManager + */ +public interface ReactiveAuthorizationEventPublisher { + + /** + * Publish the given details in the form of an event, typically + * {@link ReactiveAuthorizationGrantedEvent} or + * {@link ReactiveAuthorizationDeniedEvent}. Note that success events can be very + * noisy if enabled by default. Because of this implementations may choose to drop + * success events by default. + * @param authentication a {@link Mono} supplying the current user + * @param object the secured object + * @param decision the decision about whether the user may access the secured object + * @param the secured object's type + */ + void publishAuthorizationEvent(Mono authentication, T object, AuthorizationDecision decision); + +} diff --git a/core/src/main/java/org/springframework/security/authorization/SpringReactiveAuthorizationEventPublisher.java b/core/src/main/java/org/springframework/security/authorization/SpringReactiveAuthorizationEventPublisher.java new file mode 100644 index 00000000000..ad235c67be6 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/SpringReactiveAuthorizationEventPublisher.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2023 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 reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.authorization.event.ReactiveAuthorizationDeniedEvent; +import org.springframework.security.authorization.event.ReactiveAuthorizationGrantedEvent; +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An implementation of {@link ReactiveAuthorizationEventPublisher} that uses Spring's + * event publishing support. Because {@link ReactiveAuthorizationGrantedEvent}s typically + * require additional business logic to decide whether to publish, this implementation + * only publishes {@link ReactiveAuthorizationDeniedEvent}s. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public class SpringReactiveAuthorizationEventPublisher implements ReactiveAuthorizationEventPublisher { + + private final ApplicationEventPublisher eventPublisher; + + /** + * Construct this publisher using Spring's {@link ApplicationEventPublisher} + * @param eventPublisher the event publisher to use + */ + public SpringReactiveAuthorizationEventPublisher(ApplicationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + + @Override + public void publishAuthorizationEvent(Mono authentication, T object, + AuthorizationDecision decision) { + if (decision == null || decision.isGranted()) { + return; + } + this.eventPublisher.publishEvent(new ReactiveAuthorizationDeniedEvent<>(authentication, object, decision)); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationDeniedEvent.java b/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationDeniedEvent.java new file mode 100644 index 00000000000..285a07137b9 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationDeniedEvent.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2023 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.event; + +import reactor.core.publisher.Mono; + +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.core.Authentication; + +public class ReactiveAuthorizationDeniedEvent extends ReactiveAuthorizationEvent { + + public ReactiveAuthorizationDeniedEvent(Mono authentication, T object, + AuthorizationDecision decision) { + super(authentication, object, decision); + } + + @Override + @SuppressWarnings("unchecked") + public T getObject() { + return (T) getSource(); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationEvent.java b/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationEvent.java new file mode 100644 index 00000000000..a515e598e3d --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationEvent.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2023 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.event; + +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationEvent; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * A parent class for {@link ReactiveAuthorizationGrantedEvent} and + * {@link ReactiveAuthorizationDeniedEvent}. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public class ReactiveAuthorizationEvent extends ApplicationEvent { + + private final Mono authentication; + + private final AuthorizationDecision decision; + + /** + * Construct an {@link ReactiveAuthorizationEvent} + * @param authentication the principal requiring access + * @param object the object to which access was requested + * @param decision whether authorization was granted or denied + */ + public ReactiveAuthorizationEvent(Mono authentication, Object object, + AuthorizationDecision decision) { + super(object); + Assert.notNull(authentication, "authentication supplier cannot be null"); + this.authentication = authentication; + this.decision = decision; + } + + /** + * Get the principal requiring access + * @return the principal requiring access + */ + public Mono getAuthentication() { + return this.authentication; + } + + /** + * Get the object to which access was requested + * @return the object to which access was requested + */ + public Object getObject() { + return getSource(); + } + + /** + * Get the response to the principal's request + * @return the response to the principal's request + */ + public AuthorizationDecision getAuthorizationDecision() { + return this.decision; + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationGrantedEvent.java b/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationGrantedEvent.java new file mode 100644 index 00000000000..0cfa48c1cb3 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/event/ReactiveAuthorizationGrantedEvent.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2023 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.event; + +import reactor.core.publisher.Mono; + +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.core.Authentication; + +public class ReactiveAuthorizationGrantedEvent extends ReactiveAuthorizationEvent { + + /** + * Construct an {@link ReactiveAuthorizationGrantedEvent} + * @param authentication the principal requiring access + * @param object the object to which access was requested + * @param decision whether authorization was granted or denied + */ + public ReactiveAuthorizationGrantedEvent(Mono authentication, Object object, + AuthorizationDecision decision) { + super(authentication, object, decision); + } + + @Override + @SuppressWarnings("unchecked") + public T getObject() { + return (T) getSource(); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java index 550b8fbdef3..c259723f43d 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java @@ -35,7 +35,10 @@ import org.springframework.core.Ordered; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.util.Assert; @@ -61,6 +64,8 @@ public final class AuthorizationManagerAfterReactiveMethodInterceptor private int order = AuthorizationInterceptorsOrder.LAST.getOrder(); + private ReactiveAuthorizationEventPublisher eventPublisher = AuthorizationManagerAfterReactiveMethodInterceptor::noPublish; + /** * Creates an instance for the {@link PostAuthorize} annotation. * @return the {@link AuthorizationManagerAfterReactiveMethodInterceptor} to use @@ -149,7 +154,13 @@ private boolean isMultiValue(Class returnType, ReactiveAdapter adapter) { } private Mono postAuthorize(Mono authentication, MethodInvocation mi, Object result) { - return this.authorizationManager.verify(authentication, new MethodInvocationResult(mi, result)) + MethodInvocationResult invocationResult = new MethodInvocationResult(mi, result); + return this.authorizationManager.check(authentication, invocationResult) + .doOnNext((decision) -> this.eventPublisher.publishAuthorizationEvent(authentication, invocationResult, + decision)) + .filter(AuthorizationDecision::isGranted) + .switchIfEmpty(Mono.defer(() -> Mono.error(new AccessDeniedException("Access Denied")))) + .flatMap((decision) -> Mono.empty()) .thenReturn(result); } @@ -177,6 +188,21 @@ public void setOrder(int order) { this.order = order; } + /** + * Use this {@link ReactiveAuthorizationEventPublisher} to publish the + * {@link ReactiveAuthorizationManager} result. + * @param eventPublisher the {@link ReactiveAuthorizationEventPublisher} to use. + * @since 6.3 + */ + public void setAuthorizationEventPublisher(ReactiveAuthorizationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + + private static void noPublish(Mono authentication, T object, AuthorizationDecision decision) { + + } + /** * Inner class to avoid a hard dependency on Kotlin at runtime. */ diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java index f3d1cce8bfa..592649629e8 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java @@ -34,7 +34,10 @@ import org.springframework.core.Ordered; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.util.Assert; @@ -61,6 +64,8 @@ public final class AuthorizationManagerBeforeReactiveMethodInterceptor private int order = AuthorizationInterceptorsOrder.FIRST.getOrder(); + private ReactiveAuthorizationEventPublisher eventPublisher = AuthorizationManagerBeforeReactiveMethodInterceptor::noPublish; + /** * Creates an instance for the {@link PreAuthorize} annotation. * @return the {@link AuthorizationManagerBeforeReactiveMethodInterceptor} to use @@ -118,7 +123,11 @@ public Object invoke(MethodInvocation mi) throws Throwable { + "in order to support Reactor Context"); Mono authentication = ReactiveAuthenticationUtils.getAuthentication(); ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(type); - Mono preAuthorize = this.authorizationManager.verify(authentication, mi); + Mono preAuthorize = this.authorizationManager.check(authentication, mi) + .doOnNext((decision) -> this.eventPublisher.publishAuthorizationEvent(authentication, mi, decision)) + .filter(AuthorizationDecision::isGranted) + .switchIfEmpty(Mono.defer(() -> Mono.error(new AccessDeniedException("Access Denied")))) + .flatMap((decision) -> Mono.empty()); if (hasFlowReturnType) { if (isSuspendingFunction) { return preAuthorize.thenMany(Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi))); @@ -172,6 +181,21 @@ public void setOrder(int order) { this.order = order; } + /** + * Use this {@link ReactiveAuthorizationEventPublisher} to publish the + * {@link ReactiveAuthorizationManager} result. + * @param eventPublisher the {@link ReactiveAuthorizationEventPublisher} to use. + * @since 6.3 + */ + public void setAuthorizationEventPublisher(ReactiveAuthorizationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + + private static void noPublish(Mono authentication, T object, AuthorizationDecision decision) { + + } + /** * Inner class to avoid a hard dependency on Kotlin at runtime. */ diff --git a/core/src/test/java/org/springframework/security/authorization/SpringReactiveAuthorizationEventPublisherTests.java b/core/src/test/java/org/springframework/security/authorization/SpringReactiveAuthorizationEventPublisherTests.java new file mode 100644 index 00000000000..348610db8bf --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/SpringReactiveAuthorizationEventPublisherTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2023 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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.authorization.event.ReactiveAuthorizationDeniedEvent; +import org.springframework.security.core.Authentication; + +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link SpringReactiveAuthorizationEventPublisher} + * + * @author Marcus da Coregio + */ +class SpringReactiveAuthorizationEventPublisherTests { + + Mono authentication = Mono.just(TestAuthentication.authenticatedUser()); + + ApplicationEventPublisher applicationEventPublisher; + + SpringReactiveAuthorizationEventPublisher authorizationEventPublisher; + + @BeforeEach + void init() { + this.applicationEventPublisher = mock(ApplicationEventPublisher.class); + this.authorizationEventPublisher = new SpringReactiveAuthorizationEventPublisher( + this.applicationEventPublisher); + } + + @Test + void testAuthenticationSuccessIsNotPublished() { + AuthorizationDecision decision = new AuthorizationDecision(true); + this.authorizationEventPublisher.publishAuthorizationEvent(this.authentication, mock(Object.class), decision); + verifyNoInteractions(this.applicationEventPublisher); + } + + @Test + void testAuthenticationFailureIsPublished() { + AuthorizationDecision decision = new AuthorizationDecision(false); + this.authorizationEventPublisher.publishAuthorizationEvent(this.authentication, mock(Object.class), decision); + verify(this.applicationEventPublisher).publishEvent(isA(ReactiveAuthorizationDeniedEvent.class)); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java index 572fd754f4f..212aa7e0877 100644 --- a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java +++ b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java @@ -25,6 +25,9 @@ import org.springframework.aop.Pointcut; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.intercept.method.MockMethodInvocation; +import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.authorization.ReactiveAuthorizationManager; import static org.assertj.core.api.Assertions.assertThat; @@ -60,30 +63,32 @@ public void instantiateWhenAuthorizationManagerNullThenException() { } @Test - public void invokeMonoWhenMockReactiveAuthorizationManagerThenVerify() throws Throwable { + public void invokeMonoWhenMockReactiveAuthorizationManagerThenCheck() throws Throwable { MethodInvocation mockMethodInvocation = spy( new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), any())).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision((true)))); AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block) .isEqualTo("john"); - verify(mockReactiveAuthorizationManager).verify(any(), any()); + verify(mockReactiveAuthorizationManager).check(any(), any()); } @Test - public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Throwable { + public void invokeFluxWhenMockReactiveAuthorizationManagerThenCheck() throws Throwable { MethodInvocation mockMethodInvocation = spy( new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux"))); given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), any())).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision(true))); AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -91,7 +96,7 @@ public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Th .extracting(Flux::collectList) .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) .containsExactly("john", "bob"); - verify(mockReactiveAuthorizationManager, times(2)).verify(any(), any()); + verify(mockReactiveAuthorizationManager, times(2)).check(any(), any()); } @Test @@ -101,8 +106,8 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), any())) - .willReturn(Mono.error(new AccessDeniedException("Access Denied"))); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision(false))); AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -110,7 +115,77 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block)) .withMessage("Access Denied"); - verify(mockReactiveAuthorizationManager).verify(any(), any()); + verify(mockReactiveAuthorizationManager).check(any(), any()); + } + + @Test + void configureWhenAuthorizationEventPublisherIsNullThenIllegalArgument() { + AuthorizationManagerAfterReactiveMethodInterceptor advice = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, AuthenticatedReactiveAuthorizationManager.authenticated()); + assertThatIllegalArgumentException().isThrownBy(() -> advice.setAuthorizationEventPublisher(null)) + .withMessage("eventPublisher cannot be null"); + } + + @Test + void invokeMonoWhenAuthorizationEventPublisherAndDeniedThenPublishEvent() throws Throwable { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision(false))); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + interceptor.setAuthorizationEventPublisher(eventPublisher); + Object result = interceptor.invoke(mockMethodInvocation); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block)) + .withMessage("Access Denied"); + verify(eventPublisher).publishAuthorizationEvent(any(), any(), any()); + } + + @Test + public void invokeMonoWhenAuthorizationEventPublisherAndGrantedThenPublishEvent() throws Throwable { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision((true)))); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + interceptor.setAuthorizationEventPublisher(eventPublisher); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block) + .isEqualTo("john"); + verify(eventPublisher).publishAuthorizationEvent(any(), any(), any()); + } + + @Test + public void invokeFluxWhenAuthorizationEventPublisherAndGrantedThenPublishEventTwice() throws Throwable { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux"))); + given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), any())) + .willReturn(Mono.just(new AuthorizationDecision(true))); + AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + interceptor.setAuthorizationEventPublisher(eventPublisher); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class)) + .extracting(Flux::collectList) + .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) + .containsExactly("john", "bob"); + verify(eventPublisher, times(2)).publishAuthorizationEvent(any(), any(), any()); } class Sample { diff --git a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java index 13f6f405750..ab5551559b4 100644 --- a/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java +++ b/core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java @@ -25,6 +25,9 @@ import org.springframework.aop.Pointcut; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.intercept.method.MockMethodInvocation; +import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.authorization.ReactiveAuthorizationManager; import static org.assertj.core.api.Assertions.assertThat; @@ -61,30 +64,32 @@ public void instantiateWhenAuthorizationManagerNullThenException() { } @Test - public void invokeMonoWhenMockReactiveAuthorizationManagerThenVerify() throws Throwable { + public void invokeMonoWhenMockReactiveAuthorizationManagerThenCheck() throws Throwable { MethodInvocation mockMethodInvocation = spy( new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision(true))); AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block) .isEqualTo("john"); - verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation)); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); } @Test - public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Throwable { + public void invokeFluxWhenMockReactiveAuthorizationManagerThenCheck() throws Throwable { MethodInvocation mockMethodInvocation = spy( new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux"))); given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))).willReturn(Mono.empty()); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision(true))); AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -92,7 +97,7 @@ public void invokeFluxWhenMockReactiveAuthorizationManagerThenVerify() throws Th .extracting(Flux::collectList) .extracting(Mono::block, InstanceOfAssertFactories.list(String.class)) .containsExactly("john", "bob"); - verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation)); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); } @Test @@ -102,8 +107,8 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( ReactiveAuthorizationManager.class); - given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))) - .willReturn(Mono.error(new AccessDeniedException("Access Denied"))); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision(false))); AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( Pointcut.TRUE, mockReactiveAuthorizationManager); Object result = interceptor.invoke(mockMethodInvocation); @@ -111,7 +116,56 @@ public void invokeWhenMockReactiveAuthorizationManagerDeniedThenAccessDeniedExce .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) .extracting(Mono::block)) .withMessage("Access Denied"); - verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation)); + verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation)); + } + + @Test + void configureWhenAuthorizationEventPublisherIsNullThenIllegalArgument() { + AuthorizationManagerBeforeReactiveMethodInterceptor advice = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, AuthenticatedReactiveAuthorizationManager.authenticated()); + assertThatIllegalArgumentException().isThrownBy(() -> advice.setAuthorizationEventPublisher(null)) + .withMessage("eventPublisher cannot be null"); + } + + @Test + public void invokeMonoWhenEventPublisherAndGrantedThenPublishEvent() throws Throwable { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision(true))); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + interceptor.setAuthorizationEventPublisher(eventPublisher); + Object result = interceptor.invoke(mockMethodInvocation); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block) + .isEqualTo("john"); + verify(eventPublisher).publishAuthorizationEvent(any(), any(), any()); + } + + @Test + public void invokeMonoWhenEventPublisherAndDeniedThenPublishEvent() throws Throwable { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + MethodInvocation mockMethodInvocation = spy( + new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono"))); + given(mockMethodInvocation.proceed()).willReturn(Mono.just("john")); + ReactiveAuthorizationManager mockReactiveAuthorizationManager = mock( + ReactiveAuthorizationManager.class); + given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))) + .willReturn(Mono.just(new AuthorizationDecision(false))); + AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor( + Pointcut.TRUE, mockReactiveAuthorizationManager); + interceptor.setAuthorizationEventPublisher(eventPublisher); + Object result = interceptor.invoke(mockMethodInvocation); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class)) + .extracting(Mono::block)) + .withMessage("Access Denied"); + verify(eventPublisher).publishAuthorizationEvent(any(), any(), any()); } class Sample { diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 9dd47794062..4e8d813475d 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -134,6 +134,7 @@ ** Authorization *** xref:reactive/authorization/authorize-http-requests.adoc[Authorize HTTP Requests] *** xref:reactive/authorization/method.adoc[EnableReactiveMethodSecurity] +*** xref:reactive/authorization/events.adoc[Authorization Events] ** xref:reactive/oauth2/index.adoc[OAuth2] *** xref:reactive/oauth2/login/index.adoc[OAuth2 Log In] **** xref:reactive/oauth2/login/core.adoc[Core Configuration] diff --git a/docs/modules/ROOT/pages/reactive/authorization/events.adoc b/docs/modules/ROOT/pages/reactive/authorization/events.adoc new file mode 100644 index 00000000000..7eff6a404ab --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/authorization/events.adoc @@ -0,0 +1,168 @@ +[[reactive-authorization-events]] += Authorization Events + +For each authorization that is denied, an `ReactiveAuthorizationDeniedEvent` is fired. +Also, it's possible to fire an `ReactiveAuthorizationGrantedEvent` for authorizations that are granted. + +To listen for these events, you must first publish an `ReactiveAuthorizationEventPublisher`. + +Spring Security's `SpringReactiveAuthorizationEventPublisher` will probably do fine. +It publishes authorization denied events using Spring's `ApplicationEventPublisher`: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public ReactiveAuthorizationEventPublisher authorizationEventPublisher + (ApplicationEventPublisher applicationEventPublisher) { + return new SpringReactiveAuthorizationEventPublisher(applicationEventPublisher); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun authorizationEventPublisher + (applicationEventPublisher: ApplicationEventPublisher?): ReactiveAuthorizationEventPublisher { + return SpringReactiveAuthorizationEventPublisher(applicationEventPublisher) +} +---- +====== + +Then, you can use Spring's `@EventListener` support: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +public class AuthenticationEvents { + + @EventListener + public Mono onFailure(ReactiveAuthorizationDeniedEvent failure) { + // ... + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class AuthenticationEvents { + + @EventListener + fun onFailure(failure: ReactiveAuthorizationDeniedEvent?): Mono { + // ... + } +} +---- +====== + +[[reactive-authorization-granted-events]] +== Authorization Granted Events + +Because ``ReactiveAuthorizationGrantedEvent``s have the potential to be quite noisy, they are not published by default when using `SpringReactiveAuthorizationEventPublisher`. + +In fact, publishing these events will likely require some business logic on your part to ensure that your application is not inundated with noisy authorization events. + +You can create your own event publisher that filters success events. +For example, the following publisher only publishes authorization grants where `ROLE_ADMIN` was required: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +public class MyAuthorizationEventPublisher implements ReactiveAuthorizationEventPublisher { + private final ApplicationEventPublisher publisher; + private final SpringReactiveAuthorizationEventPublisher delegate; + + public MyAuthorizationEventPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + this.delegate = new SpringReactiveAuthorizationEventPublisher(publisher); + } + + @Override + public void publishAuthorizationEvent(Mono authentication,T object, AuthorizationDecision decision) { + if (decision == null) { + return; + } + if (!decision.isGranted()) { + this.delegate.publishAuthorizationEvent(authentication, object, decision); + return; + } + if (shouldThisEventBePublished(decision)) { + ReactiveAuthorizationGrantedEvent granted = new ReactiveAuthorizationGrantedEvent( + authentication, object, decision); + this.publisher.publishEvent(granted); + } + } + + private boolean shouldThisEventBePublished(AuthorizationDecision decision) { + if (!(decision instanceof AuthorityAuthorizationDecision)) { + return false; + } + Collection authorities = ((AuthorityAuthorizationDecision) decision).getAuthorities(); + for (GrantedAuthority authority : authorities) { + if ("ROLE_ADMIN".equals(authority.getAuthority())) { + return true; + } + } + return false; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class MyAuthorizationEventPublisher(val publisher: ApplicationEventPublisher, + val delegate: SpringReactiveAuthorizationEventPublisher = SpringReactiveAuthorizationEventPublisher(publisher)): + ReactiveAuthorizationEventPublisher { + + override fun publishAuthorizationEvent( + authentication: Mono?, + `object`: T, + decision: AuthorizationDecision? + ) { + if (decision == null) { + return + } + if (!decision.isGranted) { + this.delegate.publishAuthorizationEvent(authentication, `object`, decision) + return + } + if (shouldThisEventBePublished(decision)) { + val granted = ReactiveAuthorizationGrantedEvent(authentication, `object`, decision) + this.publisher.publishEvent(granted) + } + } + + private fun shouldThisEventBePublished(decision: AuthorizationDecision): Boolean { + if (decision !is AuthorityAuthorizationDecision) { + return false + } + val authorities = decision.authorities + for (authority in authorities) { + if ("ROLE_ADMIN" == authority.authority) { + return true + } + } + return false + } +} +---- +====== diff --git a/web/src/main/java/org/springframework/security/web/server/authorization/AuthorizationWebFilter.java b/web/src/main/java/org/springframework/security/web/server/authorization/AuthorizationWebFilter.java index 6dc4cef0fc0..7fd138ce09b 100644 --- a/web/src/main/java/org/springframework/security/web/server/authorization/AuthorizationWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/authorization/AuthorizationWebFilter.java @@ -22,9 +22,13 @@ import org.springframework.core.log.LogMessage; import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; +import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; @@ -40,6 +44,8 @@ public class AuthorizationWebFilter implements WebFilter { private ReactiveAuthorizationManager authorizationManager; + private ReactiveAuthorizationEventPublisher eventPublisher = new NoopReactiveAuthorizationEventPublisher(); + public AuthorizationWebFilter(ReactiveAuthorizationManager authorizationManager) { this.authorizationManager = authorizationManager; } @@ -49,11 +55,40 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return ReactiveSecurityContextHolder.getContext() .filter((c) -> c.getAuthentication() != null) .map(SecurityContext::getAuthentication) - .as((authentication) -> this.authorizationManager.verify(authentication, exchange)) + .as((authentication) -> check(exchange, authentication)) .doOnSuccess((it) -> logger.debug("Authorization successful")) .doOnError(AccessDeniedException.class, (ex) -> logger.debug(LogMessage.format("Authorization failed: %s", ex.getMessage()))) .switchIfEmpty(chain.filter(exchange)); } + private Mono check(ServerWebExchange exchange, Mono authentication) { + return this.authorizationManager.check(authentication, exchange) + .doOnNext((decision) -> this.eventPublisher.publishAuthorizationEvent(authentication, exchange, decision)) + .filter(AuthorizationDecision::isGranted) + .switchIfEmpty(Mono.defer(() -> Mono.error(new AccessDeniedException("Access Denied")))) + .flatMap((decision) -> Mono.empty()); + } + + /** + * Sets the {@link ReactiveAuthorizationEventPublisher} to use. The default is to not + * publish any events. + * @param eventPublisher the {@link ReactiveAuthorizationEventPublisher} to use + * @since 6.3 + */ + public void setAuthorizationEventPublisher(ReactiveAuthorizationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + + private static class NoopReactiveAuthorizationEventPublisher implements ReactiveAuthorizationEventPublisher { + + @Override + public void publishAuthorizationEvent(Mono authentication, T object, + AuthorizationDecision decision) { + + } + + } + } diff --git a/web/src/test/java/org/springframework/security/web/server/authorization/AuthorizationWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/authorization/AuthorizationWebFilterTests.java index 131a6c45809..cdc4bee33f2 100644 --- a/web/src/test/java/org/springframework/security/web/server/authorization/AuthorizationWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/authorization/AuthorizationWebFilterTests.java @@ -27,13 +27,18 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationEventPublisher; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilterChain; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; /** * @author Rob Winch @@ -71,6 +76,20 @@ public void filterWhenNoAuthenticationThenThrowsAccessDenied() { this.chainResult.assertWasNotSubscribed(); } + @Test + public void filterWhenNoAuthenticationThenDoesNotPublishEvent() { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + given(this.chain.filter(this.exchange)).willReturn(this.chainResult.mono()); + AuthorizationWebFilter filter = new AuthorizationWebFilter( + (a, e) -> a.flatMap((auth) -> Mono.error(new AccessDeniedException("Denied")))); + filter.setAuthorizationEventPublisher(eventPublisher); + Mono result = filter.filter(this.exchange, this.chain) + .contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(new SecurityContextImpl()))); + StepVerifier.create(result).expectError(AccessDeniedException.class).verify(); + verify(eventPublisher, never()).publishAuthorizationEvent(any(), any(), any()); + this.chainResult.assertWasNotSubscribed(); + } + @Test public void filterWhenAuthenticationThenThrowsAccessDenied() { given(this.chain.filter(this.exchange)).willReturn(this.chainResult.mono()); @@ -83,6 +102,36 @@ public void filterWhenAuthenticationThenThrowsAccessDenied() { this.chainResult.assertWasNotSubscribed(); } + @Test + public void filterWhenAuthenticationAndFailsThenPublishEvent() { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + given(this.chain.filter(this.exchange)).willReturn(this.chainResult.mono()); + AuthorizationWebFilter filter = new AuthorizationWebFilter( + (a, e) -> Mono.just(new AuthorizationDecision(false))); + filter.setAuthorizationEventPublisher(eventPublisher); + Mono result = filter.filter(this.exchange, this.chain) + .contextWrite( + ReactiveSecurityContextHolder.withAuthentication(new TestingAuthenticationToken("a", "b", "R"))); + StepVerifier.create(result).expectError(AccessDeniedException.class).verify(); + verify(eventPublisher).publishAuthorizationEvent(any(), any(), any()); + this.chainResult.assertWasNotSubscribed(); + } + + @Test + public void filterWhenAuthenticationAndSucceedsThenPublishEvent() { + ReactiveAuthorizationEventPublisher eventPublisher = mock(); + given(this.chain.filter(this.exchange)).willReturn(this.chainResult.mono()); + AuthorizationWebFilter filter = new AuthorizationWebFilter( + (a, e) -> Mono.just(new AuthorizationDecision(true))); + filter.setAuthorizationEventPublisher(eventPublisher); + Mono result = filter.filter(this.exchange, this.chain) + .contextWrite( + ReactiveSecurityContextHolder.withAuthentication(new TestingAuthenticationToken("a", "b", "R"))); + StepVerifier.create(result).verifyComplete(); + verify(eventPublisher).publishAuthorizationEvent(any(), any(), any()); + this.chainResult.assertWasSubscribed(); + } + @Test public void filterWhenDoesNotAccessAuthenticationThenSecurityContextNotSubscribed() { PublisherProbe context = PublisherProbe.empty();