Skip to content

Publish Authorization Events on WebFlux #14361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,10 +58,14 @@ static PreFilterAuthorizationReactiveMethodInterceptor preFilterInterceptor(
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static AuthorizationManagerBeforeReactiveMethodInterceptor preAuthorizeInterceptor(
MethodSecurityExpressionHandler expressionHandler, ObjectProvider<ObservationRegistry> registryProvider) {
MethodSecurityExpressionHandler expressionHandler, ObjectProvider<ObservationRegistry> registryProvider,
ObjectProvider<ReactiveAuthorizationEventPublisher> eventPublisherProvider) {
ReactiveAuthorizationManager<MethodInvocation> authorizationManager = manager(
new PreAuthorizeReactiveAuthorizationManager(expressionHandler), registryProvider);
return AuthorizationManagerBeforeReactiveMethodInterceptor.preAuthorize(authorizationManager);
AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = AuthorizationManagerBeforeReactiveMethodInterceptor
.preAuthorize(authorizationManager);
eventPublisherProvider.ifAvailable(interceptor::setAuthorizationEventPublisher);
return interceptor;
}

@Bean
Expand All @@ -73,10 +78,14 @@ static PostFilterAuthorizationReactiveMethodInterceptor postFilterInterceptor(
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static AuthorizationManagerAfterReactiveMethodInterceptor postAuthorizeInterceptor(
MethodSecurityExpressionHandler expressionHandler, ObjectProvider<ObservationRegistry> registryProvider) {
MethodSecurityExpressionHandler expressionHandler, ObjectProvider<ObservationRegistry> registryProvider,
ObjectProvider<ReactiveAuthorizationEventPublisher> eventPublisherProvider) {
ReactiveAuthorizationManager<MethodInvocationResult> authorizationManager = manager(
new PostAuthorizeReactiveAuthorizationManager(expressionHandler), registryProvider);
return AuthorizationManagerAfterReactiveMethodInterceptor.postAuthorize(authorizationManager);
AuthorizationManagerAfterReactiveMethodInterceptor interceptor = AuthorizationManagerAfterReactiveMethodInterceptor
.postAuthorize(authorizationManager);
eventPublisherProvider.ifAvailable(interceptor::setAuthorizationEventPublisher);
return interceptor;
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReactiveAuthorizationEvent> 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 <T> void publishAuthorizationEvent(Mono<Authentication> 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<ReactiveAuthorizationEvent> events = new ArrayBlockingQueue<>(10);

@EventListener
public void onEvent(ReactiveAuthorizationEvent event) {
events.add(event);
}

public static <T extends ReactiveAuthorizationEvent> T getEvent() {
try {
return (T) events.poll(1, TimeUnit.SECONDS);
}
catch (InterruptedException ex) {
return null;
}
}

}

@Component
public static class ReactiveListener {

static BlockingQueue<ReactiveAuthorizationEvent> events = new ArrayBlockingQueue<>(10);

@EventListener
public Mono<Void> onEvent(ReactiveAuthorizationEvent event) {
return event.getAuthentication().doOnNext((authentication) -> events.add(event)).then();
}

public static <T extends ReactiveAuthorizationEvent> T getEvent() {
try {
return (T) events.poll(1, TimeUnit.SECONDS);
}
catch (InterruptedException ex) {
return null;
}
}

}

}
Loading