From 77a6e014a9c3da916559ae7d1707b09db3ab1194 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 7 Apr 2022 17:39:10 -0600 Subject: [PATCH] Add AuthorizationManager to Messaging Closes gh-11076 --- .../web/configuration/EnableWebSecurity.java | 6 +- .../SpringWebSocketImportSelector.java | 43 + ...rizationManagerMessageMatcherRegistry.java | 354 ++++++++ ...MessageSecurityMetadataSourceRegistry.java | 4 +- ...urityWebSocketMessageBrokerConfigurer.java | 8 +- ...cherAuthorizationManagerConfiguration.java | 34 + ...ketMessageBrokerSecurityConfiguration.java | 211 +++++ ...geBrokerSecurityConfigurationDocTests.java | 176 ++++ ...ssageBrokerSecurityConfigurationTests.java | 796 ++++++++++++++++++ .../EvaluationContextPostProcessor.java | 5 +- ...dMessageSecurityMetadataSourceFactory.java | 6 +- .../MessageExpressionConfigAttribute.java | 6 +- .../expression/MessageExpressionVoter.java | 6 +- .../AuthorizationChannelInterceptor.java | 95 +++ .../intercept/ChannelSecurityInterceptor.java | 4 +- .../DefaultMessageSecurityMetadataSource.java | 4 +- .../MessageAuthorizationContext.java | 75 ++ ...MatcherDelegatingAuthorizationManager.java | 136 +++ .../MessageSecurityMetadataSource.java | 4 +- .../util/matcher/MessageMatcherEntry.java | 44 + 20 files changed, 2005 insertions(+), 12 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configuration/SpringWebSocketImportSelector.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/messaging/AuthorizationManagerMessageMatcherRegistry.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/socket/MessageMatcherAuthorizationManagerConfiguration.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationDocTests.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationTests.java create mode 100644 messaging/src/main/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptor.java create mode 100644 messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageAuthorizationContext.java create mode 100644 messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManager.java create mode 100644 messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherEntry.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/EnableWebSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/EnableWebSecurity.java index aa95ed89a5..2068874408 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/EnableWebSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/EnableWebSecurity.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2022 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. @@ -75,8 +75,8 @@ import org.springframework.security.config.annotation.web.WebSecurityConfigurer; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented -@Import({ WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class, - HttpSecurityConfiguration.class }) +@Import({ WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, SpringWebSocketImportSelector.class, + OAuth2ImportSelector.class, HttpSecurityConfiguration.class }) @EnableGlobalAuthentication @Configuration public @interface EnableWebSecurity { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/SpringWebSocketImportSelector.java b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/SpringWebSocketImportSelector.java new file mode 100644 index 0000000000..2d7ad6abd8 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/SpringWebSocketImportSelector.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2022 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.web.configuration; + +import org.springframework.context.annotation.ImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; + +/** + * Used by {@link EnableWebSecurity} to conditionally import + * {@link org.springframework.security.config.annotation.web.socket.WebSocketMessageBrokerSecurityConfiguration} + * when the AbstractWebSocketHandler is present on the classpath. + * + * @author Josh Cummings + * @since 5.7 + */ +class SpringWebSocketImportSelector implements ImportSelector { + + @Override + public String[] selectImports(AnnotationMetadata importingClassMetadata) { + if (!ClassUtils.isPresent("org.springframework.web.socket.handler.AbstractWebSocketHandler", + getClass().getClassLoader())) { + return new String[0]; + } + return new String[] { + "org.springframework.security.config.annotation.web.socket.WebSocketMessageBrokerSecurityConfiguration" }; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/messaging/AuthorizationManagerMessageMatcherRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/messaging/AuthorizationManagerMessageMatcherRegistry.java new file mode 100644 index 0000000000..b291552258 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/messaging/AuthorizationManagerMessageMatcherRegistry.java @@ -0,0 +1,354 @@ +/* + * Copyright 2002-2022 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.web.messaging; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import org.springframework.context.ApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthorityAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.messaging.access.intercept.MessageAuthorizationContext; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +import org.springframework.security.messaging.util.matcher.MessageMatcher; +import org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher; +import org.springframework.security.messaging.util.matcher.SimpMessageTypeMatcher; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.PathMatcher; + +/** + * Allows mapping security constraints using {@link MessageMatcher} to authorization + * managers. + * + * @author Josh Cummings + * @since 5.7 + */ +public final class AuthorizationManagerMessageMatcherRegistry { + + private final MessageMatcherDelegatingAuthorizationManager.Builder builder = MessageMatcherDelegatingAuthorizationManager + .builder(); + + private final ApplicationContext context; + + private PathMatcher pathMatcher; + + public AuthorizationManagerMessageMatcherRegistry(ApplicationContext context) { + this.context = context; + } + + /** + * Maps any {@link Message} to a security expression. + * @return the Expression to associate + */ + public AuthorizationManagerMessageMatcherRegistry.Constraint anyMessage() { + return matchers(MessageMatcher.ANY_MESSAGE); + } + + /** + * Maps any {@link Message} that has a null SimpMessageHeaderAccessor destination + * header (i.e. CONNECT, CONNECT_ACK, HEARTBEAT, UNSUBSCRIBE, DISCONNECT, + * DISCONNECT_ACK, OTHER) + * @return the Expression to associate + */ + public AuthorizationManagerMessageMatcherRegistry.Constraint nullDestMatcher() { + return matchers(SimpDestinationMessageMatcher.NULL_DESTINATION_MATCHER); + } + + /** + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances. + * @param typesToMatch the {@link SimpMessageType} instance to match on + * @return the {@link Constraint} associated to the matchers. + */ + public AuthorizationManagerMessageMatcherRegistry.Constraint simpTypeMatchers(SimpMessageType... typesToMatch) { + MessageMatcher[] typeMatchers = new MessageMatcher[typesToMatch.length]; + for (int i = 0; i < typesToMatch.length; i++) { + SimpMessageType typeToMatch = typesToMatch[i]; + typeMatchers[i] = new SimpMessageTypeMatcher(typeToMatch); + } + return matchers(typeMatchers); + } + + /** + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances without + * regard to the {@link SimpMessageType}. If no destination is found on the Message, + * then the Matcher returns false. + * @param patterns the patterns to create + * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher} + * from. + */ + public Constraint simpDestMatchers(String... patterns) { + return simpDestMatchers(null, patterns); + } + + /** + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances that match + * on {@code SimpMessageType.MESSAGE}. If no destination is found on the Message, then + * the Matcher returns false. + * @param patterns the patterns to create + * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher} + * from. + */ + public Constraint simpMessageDestMatchers(String... patterns) { + return simpDestMatchers(SimpMessageType.MESSAGE, patterns); + } + + /** + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances that match + * on {@code SimpMessageType.SUBSCRIBE}. If no destination is found on the Message, + * then the Matcher returns false. + * @param patterns the patterns to create + * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher} + * from. + */ + public Constraint simpSubscribeDestMatchers(String... patterns) { + return simpDestMatchers(SimpMessageType.SUBSCRIBE, patterns); + } + + /** + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances. If no + * destination is found on the Message, then the Matcher returns false. + * @param type the {@link SimpMessageType} to match on. If null, the + * {@link SimpMessageType} is not considered for matching. + * @param patterns the patterns to create + * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher} + * from. + * @return the {@link Constraint} that is associated to the {@link MessageMatcher} + */ + private Constraint simpDestMatchers(SimpMessageType type, String... patterns) { + List> matchers = new ArrayList<>(patterns.length); + for (String pattern : patterns) { + Supplier> supplier = new PathMatcherMessageMatcherBuilder(pattern, type); + MessageMatcher matcher = new SupplierMessageMatcher(supplier); + matchers.add(matcher); + } + return new Constraint(matchers); + } + + /** + * The {@link PathMatcher} to be used with the + * {@link MessageSecurityMetadataSourceRegistry#simpDestMatchers(String...)}. The + * default is to use the default constructor of {@link AntPathMatcher}. + * @param pathMatcher the {@link PathMatcher} to use. Cannot be null. + * @return the {@link MessageSecurityMetadataSourceRegistry} for further + * customization. + */ + public AuthorizationManagerMessageMatcherRegistry simpDestPathMatcher(PathMatcher pathMatcher) { + Assert.notNull(pathMatcher, "pathMatcher cannot be null"); + this.pathMatcher = pathMatcher; + return this; + } + + /** + * Maps a {@link List} of {@link MessageMatcher} instances to a security expression. + * @param matchers the {@link MessageMatcher} instances to map. + * @return The {@link Constraint} that is associated to the {@link MessageMatcher} + * instances + */ + public Constraint matchers(MessageMatcher... matchers) { + List> builders = new ArrayList<>(matchers.length); + for (MessageMatcher matcher : matchers) { + builders.add(matcher); + } + return new Constraint(builders); + } + + public AuthorizationManager> build() { + return this.builder.build(); + } + + /** + * Represents the security constraint to be applied to the {@link MessageMatcher} + * instances. + */ + public final class Constraint { + + private final List> messageMatchers; + + /** + * Creates a new instance + * @param messageMatchers the {@link MessageMatcher} instances to map to this + * constraint + */ + private Constraint(List> messageMatchers) { + Assert.notEmpty(messageMatchers, "messageMatchers cannot be null or empty"); + this.messageMatchers = messageMatchers; + } + + /** + * Shortcut for specifying {@link Message} instances require a particular role. If + * you do not want to have "ROLE_" automatically inserted see + * {@link #hasAuthority(String)}. + * @param role the role to require (i.e. USER, ADMIN, etc). Note, it should not + * start with "ROLE_" as this is automatically inserted. + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry hasRole(String role) { + return access(AuthorityAuthorizationManager.hasRole(role)); + } + + /** + * Shortcut for specifying {@link Message} instances require any of a number of + * roles. If you do not want to have "ROLE_" automatically inserted see + * {@link #hasAnyAuthority(String...)} + * @param roles the roles to require (i.e. USER, ADMIN, etc). Note, it should not + * start with "ROLE_" as this is automatically inserted. + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry hasAnyRole(String... roles) { + return access(AuthorityAuthorizationManager.hasAnyRole(roles)); + } + + /** + * Specify that {@link Message} instances require a particular authority. + * @param authority the authority to require (i.e. ROLE_USER, ROLE_ADMIN, etc). + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry hasAuthority(String authority) { + return access(AuthorityAuthorizationManager.hasAuthority(authority)); + } + + /** + * Specify that {@link Message} instances requires any of a number authorities. + * @param authorities the requests require at least one of the authorities (i.e. + * "ROLE_USER","ROLE_ADMIN" would mean either "ROLE_USER" or "ROLE_ADMIN" is + * required). + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry hasAnyAuthority(String... authorities) { + return access(AuthorityAuthorizationManager.hasAnyAuthority(authorities)); + } + + /** + * Specify that Messages are allowed by anyone. + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry permitAll() { + return access((authentication, context) -> new AuthorizationDecision(true)); + } + + /** + * Specify that Messages are not allowed by anyone. + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry denyAll() { + return access((authorization, context) -> new AuthorizationDecision(false)); + } + + /** + * Specify that Messages are allowed by any authenticated user. + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry authenticated() { + return access(AuthenticatedAuthorizationManager.authenticated()); + } + + /** + * Allows specifying that Messages are secured by an arbitrary expression + * @param authorizationManager the {@link AuthorizationManager} to secure the + * destinations + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customization + */ + public AuthorizationManagerMessageMatcherRegistry access( + AuthorizationManager> authorizationManager) { + for (MessageMatcher messageMatcher : this.messageMatchers) { + AuthorizationManagerMessageMatcherRegistry.this.builder.add(messageMatcher, authorizationManager); + } + return AuthorizationManagerMessageMatcherRegistry.this; + } + + } + + private static final class SupplierMessageMatcher implements MessageMatcher { + + private final Supplier> supplier; + + private volatile MessageMatcher delegate; + + SupplierMessageMatcher(Supplier> supplier) { + this.supplier = supplier; + } + + @Override + public boolean matches(Message message) { + if (this.delegate == null) { + synchronized (this.supplier) { + if (this.delegate == null) { + this.delegate = this.supplier.get(); + } + } + } + return this.delegate.matches(message); + } + + } + + private final class PathMatcherMessageMatcherBuilder implements Supplier> { + + private final String pattern; + + private final SimpMessageType type; + + private PathMatcherMessageMatcherBuilder(String pattern, SimpMessageType type) { + this.pattern = pattern; + this.type = type; + } + + private PathMatcher resolvePathMatcher() { + if (AuthorizationManagerMessageMatcherRegistry.this.pathMatcher != null) { + return AuthorizationManagerMessageMatcherRegistry.this.pathMatcher; + } + if (AuthorizationManagerMessageMatcherRegistry.this.context + .getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) { + return AuthorizationManagerMessageMatcherRegistry.this.context + .getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher(); + } + return new AntPathMatcher(); + } + + @Override + public MessageMatcher get() { + PathMatcher pathMatcher = resolvePathMatcher(); + if (this.type == null) { + return new SimpDestinationMessageMatcher(this.pattern, pathMatcher); + } + if (SimpMessageType.MESSAGE == this.type) { + return SimpDestinationMessageMatcher.createMessageMatcher(this.pattern, pathMatcher); + } + if (SimpMessageType.SUBSCRIBE == this.type) { + return SimpDestinationMessageMatcher.createSubscribeMatcher(this.pattern, pathMatcher); + } + throw new IllegalStateException(this.type + " is not supported since it does not have a destination"); + } + + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/messaging/MessageSecurityMetadataSourceRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/messaging/MessageSecurityMetadataSourceRegistry.java index eee7e34f36..74bd6c812c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/messaging/MessageSecurityMetadataSourceRegistry.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/messaging/MessageSecurityMetadataSourceRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2022 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. @@ -43,7 +43,9 @@ import org.springframework.util.StringUtils; * * @author Rob Winch * @since 4.0 + * @deprecated Use {@link AuthorizationManagerMessageMatcherRegistry} instead */ +@Deprecated public class MessageSecurityMetadataSourceRegistry { private static final String permitAll = "permitAll"; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/socket/AbstractSecurityWebSocketMessageBrokerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/socket/AbstractSecurityWebSocketMessageBrokerConfigurer.java index 60139cd59a..759c446117 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/socket/AbstractSecurityWebSocketMessageBrokerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/socket/AbstractSecurityWebSocketMessageBrokerConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2022 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. @@ -81,9 +81,15 @@ import org.springframework.web.socket.sockjs.transport.TransportHandlingSockJsSe * * @author Rob Winch * @since 4.0 + * @see WebSocketMessageBrokerSecurityConfiguration + * @deprecated Use + * {@link org.springframework.security.config.annotation.web.configuration.EnableWebSecurity} + * and see {@link WebSocketMessageBrokerSecurityConfiguration} for additional usage + * information instead */ @Order(Ordered.HIGHEST_PRECEDENCE + 100) @Import(ObjectPostProcessorConfiguration.class) +@Deprecated public abstract class AbstractSecurityWebSocketMessageBrokerConfigurer extends AbstractWebSocketMessageBrokerConfigurer implements SmartInitializingSingleton { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/socket/MessageMatcherAuthorizationManagerConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/socket/MessageMatcherAuthorizationManagerConfiguration.java new file mode 100644 index 0000000000..37cd48d3c8 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/socket/MessageMatcherAuthorizationManagerConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2022 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.web.socket; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.springframework.security.config.annotation.web.messaging.AuthorizationManagerMessageMatcherRegistry; + +@Configuration(proxyBeanMethods = false) +final class MessageMatcherAuthorizationManagerConfiguration { + + @Bean + @Scope("prototype") + AuthorizationManagerMessageMatcherRegistry authorizationManagerMessageMatcherRegistry(ApplicationContext context) { + return new AuthorizationManagerMessageMatcherRegistry(context); + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java new file mode 100644 index 0000000000..d8ded1edb2 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java @@ -0,0 +1,211 @@ +/* + * Copyright 2002-2022 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.web.socket; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.SpringAuthorizationEventPublisher; +import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver; +import org.springframework.security.messaging.context.SecurityContextChannelInterceptor; +import org.springframework.security.messaging.util.matcher.MessageMatcher; +import org.springframework.security.messaging.web.csrf.CsrfChannelInterceptor; +import org.springframework.security.messaging.web.socket.server.CsrfTokenHandshakeInterceptor; +import org.springframework.util.Assert; +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler; +import org.springframework.web.socket.sockjs.SockJsService; +import org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler; +import org.springframework.web.socket.sockjs.transport.TransportHandlingSockJsService; + +/** + * Allows configuring WebSocket Authorization. + * + *

+ * For example: + *

+ * + *
+ * @Configuration
+ * @EnableWebSecurity
+ * public class WebSocketSecurityConfig {
+ *
+ * 	@Bean
+ * 	AuthorizationManager<Message<?>> (AuthorizationManagerMessageMatcherRegistry messages) {
+ * 		messages.simpDestMatchers("/user/queue/errors").permitAll()
+ * 				.simpDestMatchers("/admin/**").hasRole("ADMIN").anyMessage()
+ * 				.authenticated();
+ *
+ *		return messages.build();
+ * 	}
+ * }
+ * 
+ * + * @author Josh Cummings + * @since 5.7 + */ +@Order(Ordered.HIGHEST_PRECEDENCE + 100) +@Import(MessageMatcherAuthorizationManagerConfiguration.class) +final class WebSocketMessageBrokerSecurityConfiguration + implements WebSocketMessageBrokerConfigurer, SmartInitializingSingleton { + + private static final String SIMPLE_URL_HANDLER_MAPPING_BEAN_NAME = "stompWebSocketHandlerMapping"; + + private static final AuthorizationManager> ANY_MESSAGE_AUTHENTICATED = MessageMatcherDelegatingAuthorizationManager + .builder().add(MessageMatcher.ANY_MESSAGE, AuthenticatedAuthorizationManager.authenticated()).build(); + + private ChannelInterceptor securityContextChannelInterceptor = new SecurityContextChannelInterceptor(); + + private ChannelInterceptor csrfChannelInterceptor = new CsrfChannelInterceptor(); + + private AuthorizationChannelInterceptor authorizationChannelInterceptor = new AuthorizationChannelInterceptor( + ANY_MESSAGE_AUTHENTICATED); + + private Consumer> interceptorsCustomizer = (interceptors) -> { + }; + + private ApplicationContext context; + + private AbstractSecurityWebSocketMessageBrokerConfigurer configurer; + + WebSocketMessageBrokerSecurityConfiguration(ApplicationContext context) { + this.context = context; + } + + @Override + public void addArgumentResolvers(List argumentResolvers) { + if (this.configurer != null) { + return; + } + argumentResolvers.add(new AuthenticationPrincipalArgumentResolver()); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + if (this.configurer != null) { + return; + } + this.authorizationChannelInterceptor + .setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context)); + List interceptors = new ArrayList<>(Arrays.asList(this.securityContextChannelInterceptor, + this.csrfChannelInterceptor, this.authorizationChannelInterceptor)); + this.interceptorsCustomizer.accept(interceptors); + registration.interceptors(interceptors.toArray(new ChannelInterceptor[0])); + } + + @Autowired(required = false) + void setSecurityContextChannelInterceptor(SecurityContextChannelInterceptor interceptor) { + this.securityContextChannelInterceptor = interceptor; + } + + @Autowired(required = false) + void setCsrfChannelInterceptor(CsrfChannelInterceptor csrfChannelInterceptor) { + this.csrfChannelInterceptor = csrfChannelInterceptor; + } + + @Autowired(required = false) + void setAuthorizationManager(AuthorizationManager> authorizationManager) { + this.authorizationChannelInterceptor = new AuthorizationChannelInterceptor(authorizationManager); + } + + @Autowired(required = false) + void setInterceptorsCustomizer(Consumer> interceptorsCustomizer) { + this.interceptorsCustomizer = interceptorsCustomizer; + } + + @Autowired(required = false) + @Deprecated + void setAbstractSecurityWebSocketMessageBrokerConfigurer( + AbstractSecurityWebSocketMessageBrokerConfigurer configurer) { + this.configurer = configurer; + } + + @Override + public void afterSingletonsInstantiated() { + if (this.configurer != null) { + return; + } + SimpleUrlHandlerMapping mapping = getBeanOrNull(SIMPLE_URL_HANDLER_MAPPING_BEAN_NAME, + SimpleUrlHandlerMapping.class); + if (mapping == null) { + return; + } + configureCsrf(mapping); + } + + private T getBeanOrNull(String name, Class type) { + Map beans = this.context.getBeansOfType(type); + return beans.get(name); + } + + private void configureCsrf(SimpleUrlHandlerMapping mapping) { + Map mappings = mapping.getHandlerMap(); + for (Object object : mappings.values()) { + if (object instanceof SockJsHttpRequestHandler) { + setHandshakeInterceptors((SockJsHttpRequestHandler) object); + } + else if (object instanceof WebSocketHttpRequestHandler) { + setHandshakeInterceptors((WebSocketHttpRequestHandler) object); + } + else { + throw new IllegalStateException( + "Bean " + SIMPLE_URL_HANDLER_MAPPING_BEAN_NAME + " is expected to contain mappings to either a " + + "SockJsHttpRequestHandler or a WebSocketHttpRequestHandler but got " + object); + } + } + } + + private void setHandshakeInterceptors(SockJsHttpRequestHandler handler) { + SockJsService sockJsService = handler.getSockJsService(); + Assert.state(sockJsService instanceof TransportHandlingSockJsService, + () -> "sockJsService must be instance of TransportHandlingSockJsService got " + sockJsService); + TransportHandlingSockJsService transportHandlingSockJsService = (TransportHandlingSockJsService) sockJsService; + List handshakeInterceptors = transportHandlingSockJsService.getHandshakeInterceptors(); + List interceptorsToSet = new ArrayList<>(handshakeInterceptors.size() + 1); + interceptorsToSet.add(new CsrfTokenHandshakeInterceptor()); + interceptorsToSet.addAll(handshakeInterceptors); + transportHandlingSockJsService.setHandshakeInterceptors(interceptorsToSet); + } + + private void setHandshakeInterceptors(WebSocketHttpRequestHandler handler) { + List handshakeInterceptors = handler.getHandshakeInterceptors(); + List interceptorsToSet = new ArrayList<>(handshakeInterceptors.size() + 1); + interceptorsToSet.add(new CsrfTokenHandshakeInterceptor()); + interceptorsToSet.addAll(handshakeInterceptors); + handler.setHandshakeInterceptors(interceptorsToSet); + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationDocTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationDocTests.java new file mode 100644 index 0000000000..73135841c4 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationDocTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2002-2022 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.web.socket; + +import java.util.HashMap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.mock.web.MockServletConfig; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.messaging.AuthorizationManagerMessageMatcherRegistry; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.DefaultCsrfToken; +import org.springframework.stereotype.Controller; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class WebSocketMessageBrokerSecurityConfigurationDocTests { + + AnnotationConfigWebApplicationContext context; + + TestingAuthenticationToken messageUser; + + CsrfToken token; + + String sessionAttr; + + @BeforeEach + public void setup() { + this.token = new DefaultCsrfToken("header", "param", "token"); + this.sessionAttr = "sessionAttr"; + this.messageUser = new TestingAuthenticationToken("user", "pass", "ROLE_USER"); + } + + @AfterEach + public void cleanup() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + public void securityMappings() { + loadConfig(WebSocketSecurityConfig.class); + clientInboundChannel().send(message("/user/queue/errors", SimpMessageType.SUBSCRIBE)); + assertThatExceptionOfType(MessageDeliveryException.class) + .isThrownBy(() -> clientInboundChannel().send(message("/denyAll", SimpMessageType.MESSAGE))) + .withCauseInstanceOf(AccessDeniedException.class); + } + + private void loadConfig(Class... configs) { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.register(configs); + this.context.register(WebSocketConfig.class, SyncExecutorConfig.class); + this.context.setServletConfig(new MockServletConfig()); + this.context.refresh(); + } + + private MessageChannel clientInboundChannel() { + return this.context.getBean("clientInboundChannel", MessageChannel.class); + } + + private Message message(String destination, SimpMessageType type) { + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(type); + return message(headers, destination); + } + + private Message message(SimpMessageHeaderAccessor headers, String destination) { + headers.setSessionId("123"); + headers.setSessionAttributes(new HashMap<>()); + if (destination != null) { + headers.setDestination(destination); + } + if (this.messageUser != null) { + headers.setUser(this.messageUser); + } + return new GenericMessage<>("hi", headers.getMessageHeaders()); + } + + @Controller + static class MyController { + + @MessageMapping("/authentication") + void authentication(@AuthenticationPrincipal String un) { + // ... do something ... + } + + } + + @Configuration + @EnableWebSecurity + static class WebSocketSecurityConfig { + + @Bean + AuthorizationManager> authorizationManager(AuthorizationManagerMessageMatcherRegistry messages) { + messages.nullDestMatcher().authenticated() + // <1> + .simpSubscribeDestMatchers("/user/queue/errors").permitAll() + // <2> + .simpDestMatchers("/app/**").hasRole("USER") + // <3> + .simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") // <4> + .simpTypeMatchers(SimpMessageType.MESSAGE, SimpMessageType.SUBSCRIBE).denyAll() // <5> + .anyMessage().denyAll(); // <6> + return messages.build(); + } + + } + + @Configuration + @EnableWebSocketMessageBroker + static class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/chat").withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/queue/", "/topic/"); + registry.setApplicationDestinationPrefixes("/permitAll", "/denyAll"); + } + + @Bean + MyController myController() { + return new MyController(); + } + + } + + @Configuration + static class SyncExecutorConfig { + + @Bean + static SyncExecutorSubscribableChannelPostProcessor postProcessor() { + return new SyncExecutorSubscribableChannelPostProcessor(); + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationTests.java new file mode 100644 index 0000000000..d308ced90d --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationTests.java @@ -0,0 +1,796 @@ +/* + * Copyright 2002-2022 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.web.socket; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +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.core.MethodParameter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.support.AbstractMessageChannel; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletConfig; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.messaging.AuthorizationManagerMessageMatcherRegistry; +import org.springframework.security.config.annotation.web.messaging.MessageSecurityMetadataSourceRegistry; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor; +import org.springframework.security.messaging.access.intercept.ChannelSecurityInterceptor; +import org.springframework.security.messaging.access.intercept.MessageAuthorizationContext; +import org.springframework.security.messaging.context.SecurityContextChannelInterceptor; +import org.springframework.security.messaging.web.csrf.CsrfChannelInterceptor; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.DefaultCsrfToken; +import org.springframework.security.web.csrf.MissingCsrfTokenException; +import org.springframework.stereotype.Controller; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.HttpRequestHandler; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.server.HandshakeFailureException; +import org.springframework.web.socket.server.HandshakeHandler; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; +import org.springframework.web.socket.sockjs.transport.handler.SockJsWebSocketHandler; +import org.springframework.web.socket.sockjs.transport.session.WebSocketServerSockJsSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.fail; + +public class WebSocketMessageBrokerSecurityConfigurationTests { + + AnnotationConfigWebApplicationContext context; + + TestingAuthenticationToken messageUser; + + CsrfToken token; + + String sessionAttr; + + @BeforeEach + public void setup() { + this.token = new DefaultCsrfToken("header", "param", "token"); + this.sessionAttr = "sessionAttr"; + this.messageUser = new TestingAuthenticationToken("user", "pass", "ROLE_USER"); + } + + @AfterEach + public void cleanup() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + public void simpleRegistryMappings() { + loadConfig(SockJsSecurityConfig.class); + clientInboundChannel().send(message("/permitAll")); + assertThatExceptionOfType(MessageDeliveryException.class) + .isThrownBy(() -> clientInboundChannel().send(message("/denyAll"))) + .withCauseInstanceOf(AccessDeniedException.class); + } + + @Test + public void annonymousSupported() { + loadConfig(SockJsSecurityConfig.class); + this.messageUser = null; + clientInboundChannel().send(message("/permitAll")); + } + + // gh-3797 + @Test + public void beanResolver() { + loadConfig(SockJsSecurityConfig.class); + this.messageUser = null; + clientInboundChannel().send(message("/beanResolver")); + } + + @Test + public void addsAuthenticationPrincipalResolver() { + loadConfig(SockJsSecurityConfig.class); + MessageChannel messageChannel = clientInboundChannel(); + Message message = message("/permitAll/authentication"); + messageChannel.send(message); + assertThat(this.context.getBean(MyController.class).authenticationPrincipal) + .isEqualTo((String) this.messageUser.getPrincipal()); + } + + @Test + public void addsAuthenticationPrincipalResolverWhenNoAuthorization() { + loadConfig(NoInboundSecurityConfig.class); + MessageChannel messageChannel = clientInboundChannel(); + Message message = message("/permitAll/authentication"); + messageChannel.send(message); + assertThat(this.context.getBean(MyController.class).authenticationPrincipal) + .isEqualTo((String) this.messageUser.getPrincipal()); + } + + @Test + public void addsCsrfProtectionWhenNoAuthorization() { + loadConfig(NoInboundSecurityConfig.class); + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT); + Message message = message(headers, "/authentication"); + MessageChannel messageChannel = clientInboundChannel(); + assertThatExceptionOfType(MessageDeliveryException.class).isThrownBy(() -> messageChannel.send(message)) + .withCauseInstanceOf(MissingCsrfTokenException.class); + } + + @Test + public void csrfProtectionForConnect() { + loadConfig(SockJsSecurityConfig.class); + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT); + Message message = message(headers, "/authentication"); + MessageChannel messageChannel = clientInboundChannel(); + assertThatExceptionOfType(MessageDeliveryException.class).isThrownBy(() -> messageChannel.send(message)) + .withCauseInstanceOf(MissingCsrfTokenException.class); + } + + @Test + public void csrfProtectionDisabledForConnect() { + loadConfig(CsrfDisabledSockJsSecurityConfig.class); + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT); + Message message = message(headers, "/permitAll/connect"); + MessageChannel messageChannel = clientInboundChannel(); + messageChannel.send(message); + } + + @Test + public void csrfProtectionDefinedByBean() { + loadConfig(SockJsProxylessSecurityConfig.class); + MessageChannel messageChannel = clientInboundChannel(); + Stream> interceptors = ((AbstractMessageChannel) messageChannel) + .getInterceptors().stream().map(ChannelInterceptor::getClass); + assertThat(interceptors).contains(CsrfChannelInterceptor.class); + } + + @Test + public void messagesConnectUseCsrfTokenHandshakeInterceptor() throws Exception { + loadConfig(SockJsSecurityConfig.class); + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT); + Message message = message(headers, "/authentication"); + MockHttpServletRequest request = sockjsHttpRequest("/chat"); + HttpRequestHandler handler = handler(request); + handler.handleRequest(request, new MockHttpServletResponse()); + assertHandshake(request); + } + + @Test + public void messagesConnectUseCsrfTokenHandshakeInterceptorMultipleMappings() throws Exception { + loadConfig(SockJsSecurityConfig.class); + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT); + Message message = message(headers, "/authentication"); + MockHttpServletRequest request = sockjsHttpRequest("/other"); + HttpRequestHandler handler = handler(request); + handler.handleRequest(request, new MockHttpServletResponse()); + assertHandshake(request); + } + + @Test + public void messagesConnectWebSocketUseCsrfTokenHandshakeInterceptor() throws Exception { + loadConfig(WebSocketSecurityConfig.class); + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT); + Message message = message(headers, "/authentication"); + MockHttpServletRequest request = websocketHttpRequest("/websocket"); + HttpRequestHandler handler = handler(request); + handler.handleRequest(request, new MockHttpServletResponse()); + assertHandshake(request); + } + + @Test + public void msmsRegistryCustomPatternMatcher() { + loadConfig(MsmsRegistryCustomPatternMatcherConfig.class); + clientInboundChannel().send(message("/app/a.b")); + assertThatExceptionOfType(MessageDeliveryException.class) + .isThrownBy(() -> clientInboundChannel().send(message("/app/a.b.c"))) + .withCauseInstanceOf(AccessDeniedException.class); + } + + @Test + public void overrideMsmsRegistryCustomPatternMatcher() { + loadConfig(OverrideMsmsRegistryCustomPatternMatcherConfig.class); + clientInboundChannel().send(message("/app/a/b")); + assertThatExceptionOfType(MessageDeliveryException.class) + .isThrownBy(() -> clientInboundChannel().send(message("/app/a/b/c"))) + .withCauseInstanceOf(AccessDeniedException.class); + } + + @Test + public void defaultPatternMatcher() { + loadConfig(DefaultPatternMatcherConfig.class); + clientInboundChannel().send(message("/app/a/b")); + assertThatExceptionOfType(MessageDeliveryException.class) + .isThrownBy(() -> clientInboundChannel().send(message("/app/a/b/c"))) + .withCauseInstanceOf(AccessDeniedException.class); + } + + @Test + public void customExpression() { + loadConfig(CustomExpressionConfig.class); + clientInboundChannel().send(message("/denyRob")); + this.messageUser = new TestingAuthenticationToken("rob", "password", "ROLE_USER"); + assertThatExceptionOfType(MessageDeliveryException.class) + .isThrownBy(() -> clientInboundChannel().send(message("/denyRob"))) + .withCauseInstanceOf(AccessDeniedException.class); + } + + @Test + public void channelSecurityInterceptorUsesMetadataSourceBeanWhenProxyingDisabled() { + loadConfig(SockJsProxylessSecurityConfig.class); + AbstractMessageChannel messageChannel = clientInboundChannel(); + AuthorizationManager> authorizationManager = this.context.getBean(AuthorizationManager.class); + for (ChannelInterceptor interceptor : messageChannel.getInterceptors()) { + if (interceptor instanceof AuthorizationChannelInterceptor) { + assertThat(ReflectionTestUtils.getField(interceptor, "preSendAuthorizationManager")) + .isSameAs(authorizationManager); + return; + } + } + fail("did not find AuthorizationChannelInterceptor"); + } + + @Test + public void securityContextChannelInterceptorDefinedByBean() { + loadConfig(SockJsProxylessSecurityConfig.class); + MessageChannel messageChannel = clientInboundChannel(); + Stream> interceptors = ((AbstractMessageChannel) messageChannel) + .getInterceptors().stream().map(ChannelInterceptor::getClass); + assertThat(interceptors).contains(SecurityContextChannelInterceptor.class); + } + + @Test + public void inboundChannelSecurityDefinedByBean() { + loadConfig(SockJsProxylessSecurityConfig.class); + MessageChannel messageChannel = clientInboundChannel(); + Stream> interceptors = ((AbstractMessageChannel) messageChannel) + .getInterceptors().stream().map(ChannelInterceptor::getClass); + assertThat(interceptors).contains(AuthorizationChannelInterceptor.class); + } + + @Test + public void usingLegacyThenNewFiltersNotUsed() { + loadConfig(UsingLegacyConfigurerConfig.class); + AbstractMessageChannel messageChannel = clientInboundChannel(); + List> interceptors = messageChannel.getInterceptors().stream() + .map(ChannelInterceptor::getClass).collect(Collectors.toList()); + assertThat(interceptors).contains(ChannelSecurityInterceptor.class); + assertThat(interceptors).doesNotContain(AuthorizationChannelInterceptor.class); + } + + private void assertHandshake(HttpServletRequest request) { + TestHandshakeHandler handshakeHandler = this.context.getBean(TestHandshakeHandler.class); + assertThat(handshakeHandler.attributes.get(CsrfToken.class.getName())).isSameAs(this.token); + assertThat(handshakeHandler.attributes.get(this.sessionAttr)) + .isEqualTo(request.getSession().getAttribute(this.sessionAttr)); + } + + private HttpRequestHandler handler(HttpServletRequest request) throws Exception { + HandlerMapping handlerMapping = this.context.getBean(HandlerMapping.class); + return (HttpRequestHandler) handlerMapping.getHandler(request).getHandler(); + } + + private MockHttpServletRequest websocketHttpRequest(String mapping) { + MockHttpServletRequest request = sockjsHttpRequest(mapping); + request.setRequestURI(mapping); + return request; + } + + private MockHttpServletRequest sockjsHttpRequest(String mapping) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", ""); + request.setMethod("GET"); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/289/tpyx6mde/websocket"); + request.setRequestURI(mapping + "/289/tpyx6mde/websocket"); + request.getSession().setAttribute(this.sessionAttr, "sessionValue"); + request.setAttribute(CsrfToken.class.getName(), this.token); + return request; + } + + private Message message(String destination) { + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(); + return message(headers, destination); + } + + private Message message(SimpMessageHeaderAccessor headers, String destination) { + headers.setSessionId("123"); + headers.setSessionAttributes(new HashMap<>()); + if (destination != null) { + headers.setDestination(destination); + } + if (this.messageUser != null) { + headers.setUser(this.messageUser); + } + return new GenericMessage<>("hi", headers.getMessageHeaders()); + } + + private T clientInboundChannel() { + return (T) this.context.getBean("clientInboundChannel", MessageChannel.class); + } + + private void loadConfig(Class... configs) { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.register(configs); + this.context.setServletConfig(new MockServletConfig()); + this.context.refresh(); + } + + @Configuration + @EnableWebSocketMessageBroker + @EnableWebSecurity + @Import(SyncExecutorConfig.class) + static class MsmsRegistryCustomPatternMatcherConfig implements WebSocketMessageBrokerConfigurer { + + // @formatter:off + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry + .addEndpoint("/other") + .setHandshakeHandler(testHandshakeHandler()); + } + // @formatter:on + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setPathMatcher(new AntPathMatcher(".")); + registry.enableSimpleBroker("/queue/", "/topic/"); + registry.setApplicationDestinationPrefixes("/app"); + } + + // @formatter:off + @Bean + AuthorizationManager> authorizationManager(AuthorizationManagerMessageMatcherRegistry messages) { + messages + .simpDestMatchers("/app/a.*").permitAll() + .anyMessage().denyAll(); + + return messages.build(); + } + // @formatter:on + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSocketMessageBroker + @EnableWebSecurity + @Import(SyncExecutorConfig.class) + static class OverrideMsmsRegistryCustomPatternMatcherConfig implements WebSocketMessageBrokerConfigurer { + + // @formatter:off + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry + .addEndpoint("/other") + .setHandshakeHandler(testHandshakeHandler()); + } + // @formatter:on + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setPathMatcher(new AntPathMatcher(".")); + registry.enableSimpleBroker("/queue/", "/topic/"); + registry.setApplicationDestinationPrefixes("/app"); + } + + // @formatter:off + @Bean + AuthorizationManager> authorizationManager(AuthorizationManagerMessageMatcherRegistry messages) { + messages + .simpDestPathMatcher(new AntPathMatcher()) + .simpDestMatchers("/app/a/*").permitAll() + .anyMessage().denyAll(); + return messages.build(); + } + // @formatter:on + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSocketMessageBroker + @EnableWebSecurity + @Import(SyncExecutorConfig.class) + static class DefaultPatternMatcherConfig implements WebSocketMessageBrokerConfigurer { + + // @formatter:off + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry + .addEndpoint("/other") + .setHandshakeHandler(testHandshakeHandler()); + } + // @formatter:on + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/queue/", "/topic/"); + registry.setApplicationDestinationPrefixes("/app"); + } + + // @formatter:off + @Bean + AuthorizationManager> authorizationManager(AuthorizationManagerMessageMatcherRegistry messages) { + messages + .simpDestMatchers("/app/a/*").permitAll() + .anyMessage().denyAll(); + + return messages.build(); + } + // @formatter:on + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSocketMessageBroker + @EnableWebSecurity + @Import(SyncExecutorConfig.class) + static class CustomExpressionConfig implements WebSocketMessageBrokerConfigurer { + + // @formatter:off + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry + .addEndpoint("/other") + .setHandshakeHandler(testHandshakeHandler()); + } + // @formatter:on + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/queue/", "/topic/"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Bean + AuthorizationManager> authorizationManager() { + return (authentication, message) -> { + Authentication auth = authentication.get(); + return new AuthorizationDecision(auth != null && !"rob".equals(auth.getName())); + }; + } + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Controller + static class MyController { + + String authenticationPrincipal; + + MyCustomArgument myCustomArgument; + + @MessageMapping("/authentication") + void authentication(@AuthenticationPrincipal String un) { + this.authenticationPrincipal = un; + } + + @MessageMapping("/myCustom") + void myCustom(MyCustomArgument myCustomArgument) { + this.myCustomArgument = myCustomArgument; + } + + } + + static class MyCustomArgument { + + MyCustomArgument(String notDefaultConstr) { + } + + } + + static class MyCustomArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().isAssignableFrom(MyCustomArgument.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + return new MyCustomArgument(""); + } + + } + + static class TestHandshakeHandler implements HandshakeHandler { + + Map attributes; + + @Override + public boolean doHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, + Map attributes) throws HandshakeFailureException { + this.attributes = attributes; + if (wsHandler instanceof SockJsWebSocketHandler) { + // work around SPR-12716 + SockJsWebSocketHandler sockJs = (SockJsWebSocketHandler) wsHandler; + WebSocketServerSockJsSession session = (WebSocketServerSockJsSession) ReflectionTestUtils + .getField(sockJs, "sockJsSession"); + this.attributes = session.getAttributes(); + } + return true; + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebSocketMessageBroker + @Import(SyncExecutorConfig.class) + static class SockJsSecurityConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // @formatter:off + registry.addEndpoint("/other").setHandshakeHandler(testHandshakeHandler()) + .withSockJS().setInterceptors(new HttpSessionHandshakeInterceptor()); + registry.addEndpoint("/chat").setHandshakeHandler(testHandshakeHandler()) + .withSockJS().setInterceptors(new HttpSessionHandshakeInterceptor()); + // @formatter:on + } + + // @formatter:off + @Bean + AuthorizationManager> authorizationManager(AuthorizationManagerMessageMatcherRegistry messages, + SecurityCheck security) { + AuthorizationManager> beanResolver = + (authentication, context) -> new AuthorizationDecision(security.check()); + messages + .simpDestMatchers("/permitAll/**").permitAll() + .simpDestMatchers("/beanResolver/**").access(beanResolver) + .anyMessage().denyAll(); + return messages.build(); + } + // @formatter:on + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/queue/", "/topic/"); + registry.setApplicationDestinationPrefixes("/permitAll", "/denyAll"); + } + + @Bean + MyController myController() { + return new MyController(); + } + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + @Bean + SecurityCheck security() { + return new SecurityCheck(); + } + + static class SecurityCheck { + + private boolean check; + + boolean check() { + this.check = !this.check; + return this.check; + } + + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebSocketMessageBroker + @Import(SyncExecutorConfig.class) + static class NoInboundSecurityConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // @formatter:off + registry.addEndpoint("/other") + .withSockJS().setInterceptors(new HttpSessionHandshakeInterceptor()); + registry.addEndpoint("/chat") + .withSockJS().setInterceptors(new HttpSessionHandshakeInterceptor()); + // @formatter:on + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/queue/", "/topic/"); + registry.setApplicationDestinationPrefixes("/permitAll", "/denyAll"); + } + + @Bean + MyController myController() { + return new MyController(); + } + + } + + @Configuration + @Import(SockJsSecurityConfig.class) + static class CsrfDisabledSockJsSecurityConfig { + + @Bean + Consumer> channelInterceptorCustomizer() { + return (interceptors) -> interceptors.remove(1); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebSocketMessageBroker + @Import(SyncExecutorConfig.class) + static class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // @formatter:off + registry.addEndpoint("/websocket") + .setHandshakeHandler(testHandshakeHandler()) + .addInterceptors(new HttpSessionHandshakeInterceptor()); + // @formatter:on + } + + @Bean + AuthorizationManager> authorizationManager(AuthorizationManagerMessageMatcherRegistry messages) { + // @formatter:off + messages + .simpDestMatchers("/permitAll/**").permitAll() + .anyMessage().denyAll(); + // @formatter:on + return messages.build(); + } + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebSocketMessageBroker + @Import(SyncExecutorConfig.class) + static class UsingLegacyConfigurerConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // @formatter:off + registry.addEndpoint("/websocket") + .setHandshakeHandler(testHandshakeHandler()) + .addInterceptors(new HttpSessionHandshakeInterceptor()); + // @formatter:on + } + + @Override + public void configureInbound(MessageSecurityMetadataSourceRegistry messages) { + // @formatter:off + messages + .simpDestMatchers("/permitAll/**").permitAll() + .anyMessage().denyAll(); + // @formatter:on + } + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @EnableWebSocketMessageBroker + @Import(SyncExecutorConfig.class) + static class SockJsProxylessSecurityConfig implements WebSocketMessageBrokerConfigurer { + + private ApplicationContext context; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // @formatter:off + registry.addEndpoint("/chat") + .setHandshakeHandler(this.context.getBean(TestHandshakeHandler.class)) + .withSockJS().setInterceptors(new HttpSessionHandshakeInterceptor()); + // @formatter:on + } + + @Autowired + void setContext(ApplicationContext context) { + this.context = context; + } + + // @formatter:off + @Bean + AuthorizationManager> authorizationManager(AuthorizationManagerMessageMatcherRegistry messages) { + messages + .anyMessage().denyAll(); + return messages.build(); + } + // @formatter:on + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + static class SyncExecutorConfig { + + @Bean + static SyncExecutorSubscribableChannelPostProcessor postProcessor() { + return new SyncExecutorSubscribableChannelPostProcessor(); + } + + } + +} diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/expression/EvaluationContextPostProcessor.java b/messaging/src/main/java/org/springframework/security/messaging/access/expression/EvaluationContextPostProcessor.java index ef7a10f438..baedbf632d 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/access/expression/EvaluationContextPostProcessor.java +++ b/messaging/src/main/java/org/springframework/security/messaging/access/expression/EvaluationContextPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2022 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. @@ -27,7 +27,10 @@ import org.springframework.expression.EvaluationContext; * * @author Daniel Bustamante Ospina * @since 5.2 + * @deprecated Since {@link MessageExpressionVoter} is deprecated, there is no more need + * for this class */ +@Deprecated interface EvaluationContextPostProcessor { /** diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/expression/ExpressionBasedMessageSecurityMetadataSourceFactory.java b/messaging/src/main/java/org/springframework/security/messaging/access/expression/ExpressionBasedMessageSecurityMetadataSourceFactory.java index a819ce4cd3..33e3a52df3 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/access/expression/ExpressionBasedMessageSecurityMetadataSourceFactory.java +++ b/messaging/src/main/java/org/springframework/security/messaging/access/expression/ExpressionBasedMessageSecurityMetadataSourceFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2022 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. @@ -35,7 +35,11 @@ import org.springframework.security.messaging.util.matcher.MessageMatcher; * * @author Rob Winch * @since 4.0 + * @deprecated Use + * {@link org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager} + * instead */ +@Deprecated public final class ExpressionBasedMessageSecurityMetadataSourceFactory { private ExpressionBasedMessageSecurityMetadataSourceFactory() { diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionConfigAttribute.java b/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionConfigAttribute.java index ffa96a22aa..6e2cbbb7c1 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionConfigAttribute.java +++ b/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionConfigAttribute.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2022 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. @@ -32,7 +32,11 @@ import org.springframework.util.Assert; * @author Rob Winch * @author Daniel Bustamante Ospina * @since 4.0 + * @deprecated Use + * {@link org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager} + * instead */ +@Deprecated @SuppressWarnings("serial") class MessageExpressionConfigAttribute implements ConfigAttribute, EvaluationContextPostProcessor> { diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionVoter.java b/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionVoter.java index b097df8c1e..2fc1b1c835 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionVoter.java +++ b/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageExpressionVoter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2022 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. @@ -37,7 +37,11 @@ import org.springframework.util.Assert; * @author Rob Winch * @author Daniel Bustamante Ospina * @since 4.0 + * @deprecated Use + * {@link org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager} + * instead */ +@Deprecated public class MessageExpressionVoter implements AccessDecisionVoter> { private SecurityExpressionHandler> expressionHandler = new DefaultMessageSecurityExpressionHandler<>(); diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptor.java b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptor.java new file mode 100644 index 0000000000..c0b12847e6 --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptor.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2022 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.messaging.access.intercept; + +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationEventPublisher; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.Assert; + +/** + * Authorizes {@link Message} resources using the provided {@link AuthorizationManager} + * + * @author Josh Cummings + * @since 5.7 + */ +public final class AuthorizationChannelInterceptor implements ChannelInterceptor { + + static final Supplier AUTHENTICATION_SUPPLIER = () -> { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new AuthenticationCredentialsNotFoundException( + "An Authentication object was not found in the SecurityContext"); + } + return authentication; + }; + + private final Log logger = LogFactory.getLog(this.getClass()); + + private final AuthorizationManager> preSendAuthorizationManager; + + private AuthorizationEventPublisher eventPublisher; + + /** + * Creates a new instance + * @param preSendAuthorizationManager the {@link AuthorizationManager} to use. Cannot + * be null. + * + */ + public AuthorizationChannelInterceptor(AuthorizationManager> preSendAuthorizationManager) { + Assert.notNull(preSendAuthorizationManager, "preSendAuthorizationManager cannot be null"); + this.preSendAuthorizationManager = preSendAuthorizationManager; + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + this.logger.debug(LogMessage.of(() -> "Authorizing message send")); + AuthorizationDecision decision = this.preSendAuthorizationManager.check(AUTHENTICATION_SUPPLIER, message); + this.eventPublisher.publishAuthorizationEvent(AUTHENTICATION_SUPPLIER, message, decision); + if (decision == null || !decision.isGranted()) { // default deny + this.logger.debug(LogMessage.of(() -> "Failed to authorize message with authorization manager " + + this.preSendAuthorizationManager + " and decision " + decision)); + throw new AccessDeniedException("Access Denied"); + } + this.logger.debug(LogMessage.of(() -> "Authorized message send")); + return message; + } + + /** + * Use this {@link AuthorizationEventPublisher} to publish the + * {@link AuthorizationManager} result. + * @param eventPublisher + */ + public void setAuthorizationEventPublisher(AuthorizationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + +} diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/ChannelSecurityInterceptor.java b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/ChannelSecurityInterceptor.java index 9fe9f7117f..a0e2f370ab 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/ChannelSecurityInterceptor.java +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/ChannelSecurityInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2022 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. @@ -36,7 +36,9 @@ import org.springframework.util.Assert; * * @author Rob Winch * @since 4.0 + * @deprecated Use {@link AuthorizationChannelInterceptor} instead */ +@Deprecated public final class ChannelSecurityInterceptor extends AbstractSecurityInterceptor implements ChannelInterceptor { private static final ThreadLocal tokenHolder = new ThreadLocal<>(); diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/DefaultMessageSecurityMetadataSource.java b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/DefaultMessageSecurityMetadataSource.java index 6e3eb8ba41..ff896789af 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/DefaultMessageSecurityMetadataSource.java +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/DefaultMessageSecurityMetadataSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2022 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. @@ -40,7 +40,9 @@ import org.springframework.security.messaging.util.matcher.MessageMatcher; * @since 4.0 * @see ChannelSecurityInterceptor * @see ExpressionBasedMessageSecurityMetadataSourceFactory + * @deprecated Use {@link MessageMatcherDelegatingAuthorizationManager} instead */ +@Deprecated public final class DefaultMessageSecurityMetadataSource implements MessageSecurityMetadataSource { private final Map, Collection> messageMap; diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageAuthorizationContext.java b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageAuthorizationContext.java new file mode 100644 index 0000000000..43c750d2d5 --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageAuthorizationContext.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2022 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.messaging.access.intercept; + +import java.util.Collections; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.messaging.Message; + +/** + * An {@link Message} authorization context. + * + * @author Josh Cummings + * @since 5.7 + */ +public final class MessageAuthorizationContext { + + private final Message message; + + private final Map variables; + + /** + * Creates an instance. + * @param message the {@link HttpServletRequest} to use + */ + public MessageAuthorizationContext(Message message) { + this(message, Collections.emptyMap()); + } + + /** + * Creates an instance. + * @param message the {@link HttpServletRequest} to use + * @param variables a map containing key-value pairs representing extracted variable + * names and variable values + */ + public MessageAuthorizationContext(Message message, Map variables) { + this.message = message; + this.variables = variables; + } + + /** + * Returns the {@link HttpServletRequest}. + * @return the {@link HttpServletRequest} to use + */ + public Message getMessage() { + return this.message; + } + + /** + * Returns the extracted variable values where the key is the variable name and the + * value is the variable value. + * @return a map containing key-value pairs representing extracted variable names and + * variable values + */ + public Map getVariables() { + return this.variables; + } + +} diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManager.java b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManager.java new file mode 100644 index 0000000000..b855e36ed0 --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManager.java @@ -0,0 +1,136 @@ +/* + * Copyright 2002-2022 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.messaging.access.intercept; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.messaging.Message; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.messaging.util.matcher.MessageMatcher; +import org.springframework.security.messaging.util.matcher.MessageMatcherEntry; +import org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher; +import org.springframework.util.Assert; + +/** + * An {@link AuthorizationManager} which delegates to a specific + * {@link AuthorizationManager} based on a {@link MessageMatcher} evaluation. + * + * @author Josh Cummings + * @since 5.7 + */ +public final class MessageMatcherDelegatingAuthorizationManager implements AuthorizationManager> { + + private final Log logger = LogFactory.getLog(getClass()); + + private final List>>> mappings; + + private MessageMatcherDelegatingAuthorizationManager( + List>>> mappings) { + Assert.notEmpty(mappings, "mappings cannot be empty"); + this.mappings = mappings; + } + + /** + * Delegates to a specific {@link AuthorizationManager} based on a + * {@link MessageMatcher} evaluation. + * @param authentication the {@link Supplier} of the {@link Authentication} to check + * @param message the {@link Message} to check + * @return an {@link AuthorizationDecision}. If there is no {@link MessageMatcher} + * matching the message, or the {@link AuthorizationManager} could not decide, then + * null is returned + */ + @Override + public AuthorizationDecision check(Supplier authentication, Message message) { + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Authorizing message")); + } + for (MessageMatcherEntry>> mapping : this.mappings) { + MessageMatcher matcher = mapping.getMessageMatcher(); + MessageAuthorizationContext authorizationContext = authorizationContext(matcher, message); + if (authorizationContext != null) { + AuthorizationManager> manager = mapping.getEntry(); + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Checking authorization on message using %s", manager)); + } + return manager.check(authentication, authorizationContext); + } + } + this.logger.trace("Abstaining since did not find matching MessageMatcher"); + return null; + } + + private MessageAuthorizationContext authorizationContext(MessageMatcher matcher, Message message) { + if (!matcher.matches((Message) message)) { + return null; + } + if (matcher instanceof SimpDestinationMessageMatcher) { + SimpDestinationMessageMatcher simp = (SimpDestinationMessageMatcher) matcher; + return new MessageAuthorizationContext<>(message, simp.extractPathVariables(message)); + } + return new MessageAuthorizationContext<>(message); + } + + /** + * Creates a builder for {@link MessageMatcherDelegatingAuthorizationManager}. + * @return the new {@link MessageMatcherDelegatingAuthorizationManager.Builder} + * instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for {@link MessageMatcherDelegatingAuthorizationManager}. + */ + public static final class Builder { + + private final List>>> mappings = new ArrayList<>(); + + /** + * Maps a {@link MessageMatcher} to an {@link AuthorizationManager}. + * @param matcher the {@link MessageMatcher} to use + * @param manager the {@link AuthorizationManager} to use + * @return the {@link MessageMatcherDelegatingAuthorizationManager.Builder} for + * further customizations + */ + public MessageMatcherDelegatingAuthorizationManager.Builder add(MessageMatcher matcher, + AuthorizationManager> manager) { + Assert.notNull(matcher, "matcher cannot be null"); + Assert.notNull(manager, "manager cannot be null"); + this.mappings.add(new MessageMatcherEntry<>(matcher, manager)); + return this; + } + + /** + * Creates a {@link MessageMatcherDelegatingAuthorizationManager} instance. + * @return the {@link MessageMatcherDelegatingAuthorizationManager} instance + */ + public MessageMatcherDelegatingAuthorizationManager build() { + return new MessageMatcherDelegatingAuthorizationManager(this.mappings); + } + + } + +} diff --git a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageSecurityMetadataSource.java b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageSecurityMetadataSource.java index acf6565c45..ee3a30f9b4 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageSecurityMetadataSource.java +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageSecurityMetadataSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2022 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. @@ -26,7 +26,9 @@ import org.springframework.security.access.SecurityMetadataSource; * @since 4.0 * @see ChannelSecurityInterceptor * @see DefaultMessageSecurityMetadataSource + * @deprecated Use {@link MessageMatcherDelegatingAuthorizationManager} instead */ +@Deprecated public interface MessageSecurityMetadataSource extends SecurityMetadataSource { } diff --git a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherEntry.java b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherEntry.java new file mode 100644 index 0000000000..e5366638e0 --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherEntry.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2022 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.messaging.util.matcher; + +/** + * A rich object for associating a {@link MessageMatcher} to another object. + * + * @author Josh Cummings + * @since 5.7 + */ +public class MessageMatcherEntry { + + private final MessageMatcher messageMatcher; + + private final T entry; + + public MessageMatcherEntry(MessageMatcher requestMatcher, T entry) { + this.messageMatcher = requestMatcher; + this.entry = entry; + } + + public MessageMatcher getMessageMatcher() { + return this.messageMatcher; + } + + public T getEntry() { + return this.entry; + } + +}