Skip to content
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

Spring Security Command-level Authorization Interceptors #2983

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
17 changes: 17 additions & 0 deletions spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
<artifactId>axon-messaging</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
Expand All @@ -92,6 +98,17 @@
<artifactId>spring-security-config</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<optional>true</optional>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2010-2024. Axon Framework
*
* 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.axonframework.spring.authorization;
bankras marked this conversation as resolved.
Show resolved Hide resolved

import org.axonframework.messaging.Message;
import org.axonframework.messaging.MessageDispatchInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import javax.annotation.Nonnull;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;

/**
* Message dispatch interceptor that adds the {$code username} and {$code authorities} from the authorized principle.
*
* @author Roald Bankras
* @since 4.10.0
*/
public class AuthorizationMessageDispatchInterceptor<T extends Message<?>> implements MessageDispatchInterceptor<T> {

private static final Logger log = LoggerFactory.getLogger(AuthorizationMessageDispatchInterceptor.class);

@Nonnull
@Override
public T handle(@Nonnull T message) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
smcvb marked this conversation as resolved.
Show resolved Hide resolved
if (authentication == null) {
log.debug("No authentication found");
return message;
}
log.debug("Adding message metadata for username & authorities");
Map<String, Object> authenticationDetails = new java.util.HashMap<>();
authenticationDetails.put("username", authentication.getPrincipal());
authenticationDetails.put("authorities", authentication.getAuthorities());
return (T) message.andMetaData(authenticationDetails);
}

@Nonnull
@Override
public BiFunction<Integer, T, T> handle(
@Nonnull List<? extends T> list) {
return (position, message) -> handle(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2010-2024. Axon Framework
*
* 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.axonframework.spring.authorization;

import org.axonframework.common.annotation.AnnotationUtils;
import org.axonframework.messaging.InterceptorChain;
import org.axonframework.messaging.Message;
import org.axonframework.messaging.MessageHandlerInterceptor;
import org.axonframework.messaging.unitofwork.UnitOfWork;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import javax.annotation.Nonnull;


/**
* Message interceptor that verifies authorization based on {@code @PreAuthorize} annotations on commands
bankras marked this conversation as resolved.
Show resolved Hide resolved
*
* @author Roald Bankras
*
* @since 4.10.0
*/
public class MessageAuthorizationInterceptor<T extends Message<?>> implements MessageHandlerInterceptor<T> {

private static final Logger log = LoggerFactory.getLogger(MessageAuthorizationInterceptor.class);

@Override
public Object handle(@Nonnull UnitOfWork<? extends T> unitOfWork, @Nonnull InterceptorChain interceptorChain)
throws Exception {
T message = unitOfWork.getMessage();
if(! AnnotationUtils.isAnnotationPresent(message.getPayloadType(), PreAuthorize.class)) {
return interceptorChain.proceed();
}
PreAuthorize annotation = message.getPayloadType().getAnnotation(PreAuthorize.class);
Set<GrantedAuthority> userId = Optional.ofNullable(message.getMetaData().get("authorities"))
.map(uId -> {
log.debug("Found authorities: {}", uId);
return new HashSet<>((List<GrantedAuthority>) uId);
})
.orElseThrow(() -> new UnauthorizedMessageException(
"No authorities found"));

log.debug("Authorizing for {} and {}", message.getPayloadType().getName(), annotation.value());
if (userId.contains(new SimpleGrantedAuthority(annotation.value()))) {
bankras marked this conversation as resolved.
Show resolved Hide resolved
return interceptorChain.proceed();
}
throw new UnauthorizedMessageException("Unauthorized message " + message.getIdentifier());
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2010-2024. Axon Framework
*
* 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.axonframework.spring.authorization;

import org.axonframework.common.AxonNonTransientException;

/**
* Exception indicating that a message has been rejected due to a lack of authorization.
*
* @author Roald Bankras
*
* @since 4.10.0
*/
public class UnauthorizedMessageException extends AxonNonTransientException {

/**
* Construct the exception with the given {$code message}
*
* @param message The message describing the cause
*/
public UnauthorizedMessageException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2010-2024. Axon Framework
*
* 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.axonframework.spring.authorization;
bankras marked this conversation as resolved.
Show resolved Hide resolved

import java.util.UUID;

/**
* Test event
*
* @author Roald Bankras
*/
public class AggregateCreatedEvent {
private final UUID aggregateId;

public AggregateCreatedEvent(UUID aggregateId) {
this.aggregateId = aggregateId;
}

public UUID getAggregateId() {
return aggregateId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2010-2024. Axon Framework
*
* 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.axonframework.spring.authorization;
bankras marked this conversation as resolved.
Show resolved Hide resolved

import org.axonframework.messaging.correlation.SimpleCorrelationDataProvider;
import org.axonframework.messaging.interceptors.CorrelationDataInterceptor;
import org.axonframework.test.aggregate.AggregateTestFixture;
import org.axonframework.test.aggregate.FixtureConfiguration;
import org.axonframework.test.matchers.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import java.util.UUID;

import static org.hamcrest.core.StringStartsWith.startsWith;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {AuthorizationMessageDispatchInterceptor.class, MessageAuthorizationInterceptor.class})
class AuthorizationMessageDispatchInterceptorTest {

private FixtureConfiguration<TestAggregate> fixture;

@BeforeEach
public void setUp() {
fixture = new AggregateTestFixture<>(TestAggregate.class);
}

@Test
@WithMockUser(username = "admin", authorities = {"aggregate.create"})
public void shouldAuthorizeAndPropagateUsername() {
UUID aggregateId = UUID.randomUUID();
fixture.registerCommandDispatchInterceptor(new AuthorizationMessageDispatchInterceptor<>())
.registerCommandHandlerInterceptor(new MessageAuthorizationInterceptor<>())
.registerCommandHandlerInterceptor(new CorrelationDataInterceptor<>(new SimpleCorrelationDataProvider(
"username")))
.given()
.when(new CreateAggregateCommand(aggregateId))
.expectSuccessfulHandlerExecution()
.expectResultMessageMatching(Matchers.matches(message -> ((User) message.getMetaData()
.get("username")).getUsername()
.equals("admin")));
}

@Test
public void shouldNotAuthorizeOnNoAuthentication() {
UUID aggregateId = UUID.randomUUID();
fixture.registerCommandDispatchInterceptor(new AuthorizationMessageDispatchInterceptor<>())
.registerCommandHandlerInterceptor(new MessageAuthorizationInterceptor<>())
.registerCommandHandlerInterceptor(new CorrelationDataInterceptor<>(new SimpleCorrelationDataProvider(
"username")))
.given()
.when(new CreateAggregateCommand(aggregateId))
.expectExceptionMessage("No authorities found");
}

@Test
@WithMockUser(username = "user", roles = {""})
public void shouldNotAuthorizeWhenRolesMismatch() {
UUID aggregateId = UUID.randomUUID();
fixture.registerCommandDispatchInterceptor(new AuthorizationMessageDispatchInterceptor<>())
.registerCommandHandlerInterceptor(new MessageAuthorizationInterceptor<>())
.registerCommandHandlerInterceptor(new CorrelationDataInterceptor<>(new SimpleCorrelationDataProvider(
"username")))
.given()
.when(new CreateAggregateCommand(aggregateId))
.expectException(UnauthorizedMessageException.class)
.expectExceptionMessage(startsWith("Unauthorized message "));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2010-2024. Axon Framework
*
* 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.axonframework.spring.authorization;
bankras marked this conversation as resolved.
Show resolved Hide resolved

import org.springframework.security.access.prepost.PreAuthorize;

import java.util.UUID;

/**
* Test command with authorization annotation
*
* @author Roald Bankras
*/
@PreAuthorize("aggregate.create")
public class CreateAggregateCommand {

private final UUID aggregateId;

public CreateAggregateCommand(UUID aggregateId) {
this.aggregateId = aggregateId;
}

public UUID getAggregateId() {
return aggregateId;
}
}
Loading
Loading