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..dda9e7bea8 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. @@ -28,6 +28,7 @@ import org.springframework.security.access.expression.SecurityExpressionHandler; import org.springframework.security.config.annotation.web.configurers.RememberMeConfigurer; import org.springframework.security.messaging.access.expression.DefaultMessageSecurityExpressionHandler; import org.springframework.security.messaging.access.expression.ExpressionBasedMessageSecurityMetadataSourceFactory; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import org.springframework.security.messaging.access.intercept.MessageSecurityMetadataSource; import org.springframework.security.messaging.util.matcher.MessageMatcher; import org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher; @@ -43,7 +44,9 @@ import org.springframework.util.StringUtils; * * @author Rob Winch * @since 4.0 + * @deprecated Use {@link MessageMatcherDelegatingAuthorizationManager} 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 6c29525dde..2d3d6c0060 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,12 @@ import org.springframework.web.socket.sockjs.transport.TransportHandlingSockJsSe * * @author Rob Winch * @since 4.0 + * @see WebSocketMessageBrokerSecurityConfiguration + * @deprecated Use {@link EnableWebSocketSecurity} instead */ @Order(Ordered.HIGHEST_PRECEDENCE + 100) @Import(ObjectPostProcessorConfiguration.class) +@Deprecated public abstract class AbstractSecurityWebSocketMessageBrokerConfigurer implements WebSocketMessageBrokerConfigurer, SmartInitializingSingleton { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/socket/EnableWebSocketSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/socket/EnableWebSocketSecurity.java new file mode 100644 index 0000000000..e80671aa50 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/socket/EnableWebSocketSecurity.java @@ -0,0 +1,58 @@ +/* + * 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.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Import; + +/** + * Allows configuring WebSocket Authorization. + * + *

+ * For example: + *

+ * + *
+ * @Configuration
+ * @EnableWebSocketSecurity
+ * public class WebSocketSecurityConfig {
+ *
+ * 	@Bean
+ * 	AuthorizationManager<Message<?>> (MessageMatcherDelegatingAuthorizationManager.Builder messages) {
+ * 		messages.simpDestMatchers("/user/queue/errors").permitAll()
+ * 				.simpDestMatchers("/admin/**").hasRole("ADMIN")
+ * 				.anyMessage().authenticated();
+ *		return messages.build();
+ * 	}
+ * }
+ * 
+ * + * @author Josh Cummings + * @since 5.8 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Import(WebSocketMessageBrokerSecurityConfiguration.class) +public @interface EnableWebSocketSecurity { + +} 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..930ce77353 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/socket/MessageMatcherAuthorizationManagerConfiguration.java @@ -0,0 +1,38 @@ +/* + * 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.Scope; +import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +import org.springframework.util.AntPathMatcher; + +final class MessageMatcherAuthorizationManagerConfiguration { + + @Bean + @Scope("prototype") + MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder( + ApplicationContext context) { + return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher( + () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) + ? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher() + : new AntPathMatcher()); + } + +} 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..8c5db34fb5 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java @@ -0,0 +1,145 @@ +/* + * 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.List; +import java.util.Map; + +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.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.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; + +@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 MessageMatcherDelegatingAuthorizationManager b; + + private static final AuthorizationManager> ANY_MESSAGE_AUTHENTICATED = MessageMatcherDelegatingAuthorizationManager + .builder().anyMessage().authenticated().build(); + + private final ChannelInterceptor securityContextChannelInterceptor = new SecurityContextChannelInterceptor(); + + private final ChannelInterceptor csrfChannelInterceptor = new CsrfChannelInterceptor(); + + private AuthorizationChannelInterceptor authorizationChannelInterceptor = new AuthorizationChannelInterceptor( + ANY_MESSAGE_AUTHENTICATED); + + private ApplicationContext context; + + WebSocketMessageBrokerSecurityConfiguration(ApplicationContext context) { + this.context = context; + } + + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(new AuthenticationPrincipalArgumentResolver()); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + this.authorizationChannelInterceptor + .setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context)); + registration.interceptors(this.securityContextChannelInterceptor, this.csrfChannelInterceptor, + this.authorizationChannelInterceptor); + } + + @Autowired(required = false) + void setAuthorizationManager(AuthorizationManager> authorizationManager) { + this.authorizationChannelInterceptor = new AuthorizationChannelInterceptor(authorizationManager); + } + + @Override + public void afterSingletonsInstantiated() { + 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/main/java/org/springframework/security/config/websocket/WebSocketMessageBrokerSecurityBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/websocket/WebSocketMessageBrokerSecurityBeanDefinitionParser.java index ba8d3ac4e0..246235a955 100644 --- a/config/src/main/java/org/springframework/security/config/websocket/WebSocketMessageBrokerSecurityBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/websocket/WebSocketMessageBrokerSecurityBeanDefinitionParser.java @@ -19,6 +19,7 @@ package org.springframework.security.config.websocket; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import org.w3c.dom.Element; @@ -37,20 +38,34 @@ import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.beans.factory.xml.BeanDefinitionParser; import org.springframework.beans.factory.xml.ParserContext; import org.springframework.beans.factory.xml.XmlReaderContext; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.messaging.Message; import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; +import org.springframework.security.access.expression.ExpressionUtils; +import org.springframework.security.access.expression.SecurityExpressionHandler; import org.springframework.security.access.vote.ConsensusBased; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.Elements; +import org.springframework.security.core.Authentication; import org.springframework.security.messaging.access.expression.ExpressionBasedMessageSecurityMetadataSourceFactory; +import org.springframework.security.messaging.access.expression.MessageAuthorizationContextSecurityExpressionHandler; import org.springframework.security.messaging.access.expression.MessageExpressionVoter; +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.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.util.matcher.SimpDestinationMessageMatcher; import org.springframework.security.messaging.util.matcher.SimpMessageTypeMatcher; import org.springframework.security.messaging.web.csrf.CsrfChannelInterceptor; import org.springframework.security.messaging.web.socket.server.CsrfTokenHandshakeInterceptor; import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; import org.springframework.util.PathMatcher; import org.springframework.util.StringUtils; import org.springframework.util.xml.DomUtils; @@ -99,6 +114,10 @@ public final class WebSocketMessageBrokerSecurityBeanDefinitionParser implements private static final String DISABLED_ATTR = "same-origin-disabled"; + private static final String USE_AUTHORIZATION_MANAGER_ATTR = "use-authorization-manager"; + + private static final String AUTHORIZATION_MANAGER_REF_ATTR = "authorization-manager-ref"; + private static final String PATTERN_ATTR = "pattern"; private static final String ACCESS_ATTR = "access"; @@ -114,14 +133,83 @@ public final class WebSocketMessageBrokerSecurityBeanDefinitionParser implements */ @Override public BeanDefinition parse(Element element, ParserContext parserContext) { + String id = element.getAttribute(ID_ATTR); + String inSecurityInterceptorName = parseAuthorization(element, parserContext); + BeanDefinitionRegistry registry = parserContext.getRegistry(); + if (StringUtils.hasText(id)) { + registry.registerAlias(inSecurityInterceptorName, id); + if (!registry.containsBeanDefinition(PATH_MATCHER_BEAN_NAME)) { + registry.registerBeanDefinition(PATH_MATCHER_BEAN_NAME, new RootBeanDefinition(AntPathMatcher.class)); + } + } + else { + boolean sameOriginDisabled = Boolean.parseBoolean(element.getAttribute(DISABLED_ATTR)); + XmlReaderContext context = parserContext.getReaderContext(); + BeanDefinitionBuilder mspp = BeanDefinitionBuilder.rootBeanDefinition(MessageSecurityPostProcessor.class); + mspp.addConstructorArgValue(inSecurityInterceptorName); + mspp.addConstructorArgValue(sameOriginDisabled); + context.registerWithGeneratedName(mspp.getBeanDefinition()); + } + return null; + } + + private String parseAuthorization(Element element, ParserContext parserContext) { + boolean useAuthorizationManager = Boolean.parseBoolean(element.getAttribute(USE_AUTHORIZATION_MANAGER_ATTR)); + if (useAuthorizationManager) { + return parseAuthorizationManager(element, parserContext); + } + if (StringUtils.hasText(element.getAttribute(AUTHORIZATION_MANAGER_REF_ATTR))) { + return parseAuthorizationManager(element, parserContext); + } + return parseSecurityMetadataSource(element, parserContext); + } + + private String parseAuthorizationManager(Element element, ParserContext parserContext) { + XmlReaderContext context = parserContext.getReaderContext(); + String mdsId = createAuthorizationManager(element, parserContext); + BeanDefinitionBuilder inboundChannelSecurityInterceptor = BeanDefinitionBuilder + .rootBeanDefinition(AuthorizationChannelInterceptor.class); + inboundChannelSecurityInterceptor.addConstructorArgReference(mdsId); + return context.registerWithGeneratedName(inboundChannelSecurityInterceptor.getBeanDefinition()); + } + + private String createAuthorizationManager(Element element, ParserContext parserContext) { + XmlReaderContext context = parserContext.getReaderContext(); + String authorizationManagerRef = element.getAttribute(AUTHORIZATION_MANAGER_REF_ATTR); + if (StringUtils.hasText(authorizationManagerRef)) { + return authorizationManagerRef; + } + Element expressionHandlerElt = DomUtils.getChildElementByTagName(element, Elements.EXPRESSION_HANDLER); + String expressionHandlerRef = (expressionHandlerElt != null) ? expressionHandlerElt.getAttribute("ref") : null; + ManagedMap matcherToExpression = new ManagedMap<>(); + List interceptMessages = DomUtils.getChildElementsByTagName(element, Elements.INTERCEPT_MESSAGE); + for (Element interceptMessage : interceptMessages) { + String matcherPattern = interceptMessage.getAttribute(PATTERN_ATTR); + String accessExpression = interceptMessage.getAttribute(ACCESS_ATTR); + String messageType = interceptMessage.getAttribute(TYPE_ATTR); + BeanDefinition matcher = createMatcher(matcherPattern, messageType, parserContext, interceptMessage); + BeanDefinitionBuilder authorizationManager = BeanDefinitionBuilder + .rootBeanDefinition(ExpressionBasedAuthorizationManager.class); + if (StringUtils.hasText(expressionHandlerRef)) { + authorizationManager.addConstructorArgReference(expressionHandlerRef); + } + authorizationManager.addConstructorArgValue(accessExpression); + matcherToExpression.put(matcher, authorizationManager.getBeanDefinition()); + } + BeanDefinitionBuilder mds = BeanDefinitionBuilder + .rootBeanDefinition(MessageMatcherDelegatingAuthorizationManagerFactory.class); + mds.setFactoryMethod("createMessageMatcherDelegatingAuthorizationManager"); + mds.addConstructorArgValue(matcherToExpression); + return context.registerWithGeneratedName(mds.getBeanDefinition()); + } + + private String parseSecurityMetadataSource(Element element, ParserContext parserContext) { BeanDefinitionRegistry registry = parserContext.getRegistry(); XmlReaderContext context = parserContext.getReaderContext(); ManagedMap matcherToExpression = new ManagedMap<>(); - String id = element.getAttribute(ID_ATTR); Element expressionHandlerElt = DomUtils.getChildElementByTagName(element, Elements.EXPRESSION_HANDLER); String expressionHandlerRef = (expressionHandlerElt != null) ? expressionHandlerElt.getAttribute("ref") : null; boolean expressionHandlerDefined = StringUtils.hasText(expressionHandlerRef); - boolean sameOriginDisabled = Boolean.parseBoolean(element.getAttribute(DISABLED_ATTR)); List interceptMessages = DomUtils.getChildElementsByTagName(element, Elements.INTERCEPT_MESSAGE); for (Element interceptMessage : interceptMessages) { String matcherPattern = interceptMessage.getAttribute(PATTERN_ATTR); @@ -151,21 +239,7 @@ public final class WebSocketMessageBrokerSecurityBeanDefinitionParser implements .rootBeanDefinition(ChannelSecurityInterceptor.class); inboundChannelSecurityInterceptor.addConstructorArgValue(registry.getBeanDefinition(mdsId)); inboundChannelSecurityInterceptor.addPropertyValue("accessDecisionManager", adm.getBeanDefinition()); - String inSecurityInterceptorName = context - .registerWithGeneratedName(inboundChannelSecurityInterceptor.getBeanDefinition()); - if (StringUtils.hasText(id)) { - registry.registerAlias(inSecurityInterceptorName, id); - if (!registry.containsBeanDefinition(PATH_MATCHER_BEAN_NAME)) { - registry.registerBeanDefinition(PATH_MATCHER_BEAN_NAME, new RootBeanDefinition(AntPathMatcher.class)); - } - } - else { - BeanDefinitionBuilder mspp = BeanDefinitionBuilder.rootBeanDefinition(MessageSecurityPostProcessor.class); - mspp.addConstructorArgValue(inSecurityInterceptorName); - mspp.addConstructorArgValue(sameOriginDisabled); - context.registerWithGeneratedName(mspp.getBeanDefinition()); - } - return null; + return context.registerWithGeneratedName(inboundChannelSecurityInterceptor.getBeanDefinition()); } private BeanDefinition createMatcher(String matcherPattern, String messageType, ParserContext parserContext, @@ -341,4 +415,48 @@ public final class WebSocketMessageBrokerSecurityBeanDefinitionParser implements } + private static final class ExpressionBasedAuthorizationManager + implements AuthorizationManager> { + + private final SecurityExpressionHandler> expressionHandler; + + private final Expression expression; + + private ExpressionBasedAuthorizationManager(String expression) { + this(new MessageAuthorizationContextSecurityExpressionHandler(), expression); + } + + private ExpressionBasedAuthorizationManager( + SecurityExpressionHandler> expressionHandler, String expression) { + Assert.notNull(expressionHandler, "expressionHandler cannot be null"); + Assert.notNull(expression, "expression cannot be null"); + this.expressionHandler = expressionHandler; + this.expression = this.expressionHandler.getExpressionParser().parseExpression(expression); + } + + @Override + public AuthorizationDecision check(Supplier authentication, + MessageAuthorizationContext object) { + EvaluationContext context = this.expressionHandler.createEvaluationContext(authentication, object); + boolean granted = ExpressionUtils.evaluateAsBoolean(this.expression, context); + return new AuthorizationDecision(granted); + } + + } + + private static class MessageMatcherDelegatingAuthorizationManagerFactory { + + private static AuthorizationManager> createMessageMatcherDelegatingAuthorizationManager( + Map, AuthorizationManager>> beans) { + MessageMatcherDelegatingAuthorizationManager.Builder builder = MessageMatcherDelegatingAuthorizationManager + .builder(); + for (Map.Entry, AuthorizationManager>> entry : beans + .entrySet()) { + builder.matchers(entry.getKey()).access(entry.getValue()); + } + return builder.anyMessage().permitAll().build(); + } + + } + } diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-5.8.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-5.8.rnc index 0a79d344e8..24d08c9e16 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-5.8.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-5.8.rnc @@ -291,6 +291,12 @@ websocket-message-broker.attrlist &= websocket-message-broker.attrlist &= ## Disables the requirement for CSRF token to be present in the Stomp headers (default false). Changing the default is useful if it is necessary to allow other origins to make SockJS connections. attribute same-origin-disabled {xsd:boolean}? +websocket-message-broker.attlist &= + ## Use this AuthorizationManager instead of deriving one from elements + attribute authorization-manager-ref {xsd:string}? +websocket-message-broker.attrlist &= + ## Use AuthorizationManager API instead of SecurityMetadatasource + attribute use-authorization-manager {xsd:boolean}? intercept-message = ## Creates an authorization rule for a websocket message. diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-5.8.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-5.8.xsd index fb3271c2cc..c9e2322fa8 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-5.8.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-5.8.xsd @@ -915,6 +915,20 @@ + + + Use AuthorizationManager API instead of SecurityMetadatasource + + + + + + + + Use this AuthorizationManager instead of deriving one from <intercept-message> elements + + + diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc index 18680ebd75..bde71706d4 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc @@ -291,6 +291,12 @@ websocket-message-broker.attrlist &= websocket-message-broker.attrlist &= ## Disables the requirement for CSRF token to be present in the Stomp headers (default false). Changing the default is useful if it is necessary to allow other origins to make SockJS connections. attribute same-origin-disabled {xsd:boolean}? +websocket-message-broker.attlist &= + ## Use this AuthorizationManager instead of deriving one from elements + attribute authorization-manager-ref {xsd:string}? +websocket-message-broker.attrlist &= + ## Use AuthorizationManager API instead of SecurityMetadatasource + attribute use-authorization-manager {xsd:boolean}? intercept-message = ## Creates an authorization rule for a websocket message. diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd index d1895d603f..6ea105a688 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd @@ -915,6 +915,20 @@ + + + Use AuthorizationManager API instead of SecurityMetadatasource + + + + + + + + Use this AuthorizationManager instead of deriving one from <intercept-message> elements + + + 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..54372a3a56 --- /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.core.annotation.AuthenticationPrincipal; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +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 + @EnableWebSocketSecurity + static class WebSocketSecurityConfig { + + @Bean + AuthorizationManager> authorizationManager( + MessageMatcherDelegatingAuthorizationManager.Builder 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..d2db1059a0 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationTests.java @@ -0,0 +1,785 @@ +/* + * 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.Stream; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +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.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.MessageAuthorizationContext; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +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 + @Disabled // to be added back in with the introduction of DSL support + 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); + } + + 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 + @EnableWebSocketSecurity + @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(MessageMatcherDelegatingAuthorizationManager.Builder messages) { + messages + .simpDestMatchers("/app/a.*").permitAll() + .anyMessage().denyAll(); + + return messages.build(); + } + // @formatter:on + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSocketMessageBroker + @EnableWebSocketSecurity + @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(MessageMatcherDelegatingAuthorizationManager.Builder messages) { + messages + .simpDestPathMatcher(new AntPathMatcher()) + .simpDestMatchers("/app/a/*").permitAll() + .anyMessage().denyAll(); + return messages.build(); + } + // @formatter:on + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSocketMessageBroker + @EnableWebSocketSecurity + @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(MessageMatcherDelegatingAuthorizationManager.Builder messages) { + messages + .simpDestMatchers("/app/a/*").permitAll() + .anyMessage().denyAll(); + + return messages.build(); + } + // @formatter:on + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSocketMessageBroker + @EnableWebSocketSecurity + @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 + @EnableWebSocketSecurity + @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(MessageMatcherDelegatingAuthorizationManager.Builder 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 + @EnableWebSocketSecurity + @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 + @EnableWebSocketSecurity + @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( + MessageMatcherDelegatingAuthorizationManager.Builder messages) { + // @formatter:off + messages + .simpDestMatchers("/permitAll/**").permitAll() + .anyMessage().denyAll(); + // @formatter:on + return messages.build(); + } + + @Bean + TestHandshakeHandler testHandshakeHandler() { + return new TestHandshakeHandler(); + } + + } + + @Configuration + @EnableWebSocketSecurity + @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) + @EnableWebSocketSecurity + @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(MessageMatcherDelegatingAuthorizationManager.Builder 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/config/src/test/java/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests.java b/config/src/test/java/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests.java index 7496bc9c2d..7dadcf9517 100644 --- a/config/src/test/java/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests.java @@ -18,6 +18,7 @@ package org.springframework.security.config.websocket; import java.util.HashMap; import java.util.Map; +import java.util.function.Supplier; import org.assertj.core.api.ThrowableAssert; import org.junit.jupiter.api.Test; @@ -33,6 +34,8 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProce import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.core.MethodParameter; import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.http.server.ServerHttpRequest; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -44,6 +47,8 @@ import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.messaging.support.GenericMessage; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.expression.SecurityExpressionOperations; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.core.Authentication; @@ -68,6 +73,9 @@ import org.springframework.web.socket.server.HandshakeHandler; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; /** @@ -168,6 +176,78 @@ public class WebSocketMessageBrokerConfigTests { send(message); } + @Test + public void sendWhenNoIdSpecifiedThenIntegratesWithAuthorizationManager() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + this.clientInboundChannel.send(message("/permitAll")); + assertThatExceptionOfType(Exception.class).isThrownBy(() -> this.clientInboundChannel.send(message("/denyAll"))) + .withCauseInstanceOf(AccessDeniedException.class); + } + + @Test + public void sendWhenAnonymousMessageWithConnectMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT); + headers.setNativeHeader(this.token.getHeaderName(), this.token.getToken()); + this.clientInboundChannel.send(message("/permitAll", headers)); + } + + @Test + public void sendWhenAnonymousMessageWithConnectAckMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.CONNECT_ACK); + send(message); + } + + @Test + public void sendWhenAnonymousMessageWithDisconnectMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.DISCONNECT); + send(message); + } + + @Test + public void sendWhenAnonymousMessageWithDisconnectAckMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.DISCONNECT_ACK); + send(message); + } + + @Test + public void sendWhenAnonymousMessageWithHeartbeatMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.HEARTBEAT); + send(message); + } + + @Test + public void sendWhenAnonymousMessageWithMessageMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.MESSAGE); + send(message); + } + + @Test + public void sendWhenAnonymousMessageWithOtherMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.OTHER); + send(message); + } + + @Test + public void sendWhenAnonymousMessageWithSubscribeMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.SUBSCRIBE); + send(message); + } + + @Test + public void sendWhenAnonymousMessageWithUnsubscribeMessageTypeThenAuthorizationManagerPermits() { + this.spring.configLocations(xml("NoIdAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.UNSUBSCRIBE); + send(message); + } + @Test public void sendWhenConnectWithoutCsrfTokenThenDenied() { this.spring.configLocations(xml("SyncConfig")).autowire(); @@ -196,6 +276,19 @@ public class WebSocketMessageBrokerConfigTests { .withCauseInstanceOf(AccessDeniedException.class); } + @Test + public void sendWhenInterceptWiredForMessageTypeThenAuthorizationManagerDeniesOnTypeMismatch() { + this.spring.configLocations(xml("MessageInterceptTypeAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.MESSAGE); + send(message); + message = message("/permitAll", SimpMessageType.UNSUBSCRIBE); + assertThatExceptionOfType(Exception.class).isThrownBy(send(message)) + .withCauseInstanceOf(AccessDeniedException.class); + message = message("/anyOther", SimpMessageType.MESSAGE); + assertThatExceptionOfType(Exception.class).isThrownBy(send(message)) + .withCauseInstanceOf(AccessDeniedException.class); + } + @Test public void sendWhenInterceptWiredForSubscribeTypeThenDeniesOnTypeMismatch() { this.spring.configLocations(xml("SubscribeInterceptTypeConfig")).autowire(); @@ -209,6 +302,19 @@ public class WebSocketMessageBrokerConfigTests { .withCauseInstanceOf(AccessDeniedException.class); } + @Test + public void sendWhenInterceptWiredForSubscribeTypeThenAuthorizationManagerDeniesOnTypeMismatch() { + this.spring.configLocations(xml("SubscribeInterceptTypeAuthorizationManager")).autowire(); + Message message = message("/permitAll", SimpMessageType.SUBSCRIBE); + send(message); + message = message("/permitAll", SimpMessageType.UNSUBSCRIBE); + assertThatExceptionOfType(Exception.class).isThrownBy(send(message)) + .withCauseInstanceOf(AccessDeniedException.class); + message = message("/anyOther", SimpMessageType.SUBSCRIBE); + assertThatExceptionOfType(Exception.class).isThrownBy(send(message)) + .withCauseInstanceOf(AccessDeniedException.class); + } + @Test public void configureWhenUsingConnectMessageTypeThenAutowireFails() { assertThatExceptionOfType(BeanDefinitionParsingException.class) @@ -309,6 +415,16 @@ public class WebSocketMessageBrokerConfigTests { send(message); } + @Test + public void sendWhenUsingCustomPathMatcherThenAuthorizationManagerAppliesIt() { + this.spring.configLocations(xml("CustomPathMatcherAuthorizationManager")).autowire(); + Message message = message("/denyAll.a"); + assertThatExceptionOfType(Exception.class).isThrownBy(send(message)) + .withCauseInstanceOf(AccessDeniedException.class); + message = message("/denyAll.a.b"); + send(message); + } + @Test public void sendWhenIdSpecifiedThenSecurityDoesNotIntegrateWithClientInboundChannel() { this.spring.configLocations(xml("IdConfig")).autowire(); @@ -342,6 +458,27 @@ public class WebSocketMessageBrokerConfigTests { .withCauseInstanceOf(AccessDeniedException.class); } + @Test + @WithMockUser(username = "nile") + public void sendWhenCustomExpressionHandlerThenAuthorizationManagerAuthorizesAccordingly() { + this.spring.configLocations(xml("CustomExpressionHandlerAuthorizationManager")).autowire(); + Message message = message("/denyNile"); + assertThatExceptionOfType(Exception.class).isThrownBy(send(message)) + .withCauseInstanceOf(AccessDeniedException.class); + } + + @Test + public void sendWhenCustomAuthorizationManagerThenAuthorizesAccordingly() { + this.spring.configLocations(xml("CustomAuthorizationManagerConfig")).autowire(); + AuthorizationManager> authorizationManager = this.spring.getContext() + .getBean(AuthorizationManager.class); + given(authorizationManager.check(any(), any())).willReturn(new AuthorizationDecision(false)); + Message message = message("/any"); + assertThatExceptionOfType(Exception.class).isThrownBy(send(message)) + .withCauseInstanceOf(AccessDeniedException.class); + verify(authorizationManager).check(any(), any()); + } + private String xml(String configName) { return CONFIG_LOCATION_PREFIX + "-" + configName + ".xml"; } @@ -466,6 +603,17 @@ public class WebSocketMessageBrokerConfigTests { }; } + @Override + public EvaluationContext createEvaluationContext(Supplier authentication, + Message message) { + return new StandardEvaluationContext(new MessageSecurityExpressionRoot(authentication, message) { + public boolean denyNile() { + Authentication auth = getAuthentication(); + return auth != null && !"nile".equals(auth.getName()); + } + }); + } + } } diff --git a/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomAuthorizationManagerConfig.xml b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomAuthorizationManagerConfig.xml new file mode 100644 index 0000000000..5827be1b75 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomAuthorizationManagerConfig.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomExpressionHandlerAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomExpressionHandlerAuthorizationManager.xml new file mode 100644 index 0000000000..926b5b1120 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomExpressionHandlerAuthorizationManager.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomPathMatcherAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomPathMatcherAuthorizationManager.xml new file mode 100644 index 0000000000..c4cd2563af --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-CustomPathMatcherAuthorizationManager.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-MessageInterceptTypeAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-MessageInterceptTypeAuthorizationManager.xml new file mode 100644 index 0000000000..e636e41185 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-MessageInterceptTypeAuthorizationManager.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-NoIdAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-NoIdAuthorizationManager.xml new file mode 100644 index 0000000000..b755379760 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-NoIdAuthorizationManager.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-SubscribeInterceptTypeAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-SubscribeInterceptTypeAuthorizationManager.xml new file mode 100644 index 0000000000..d7f62dd032 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests-SubscribeInterceptTypeAuthorizationManager.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/websocket.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/websocket.adoc index fde54bc642..ae64507737 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/websocket.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/websocket.adoc @@ -38,6 +38,12 @@ If not specified, Spring Security will automatically integrate with the messagin * **same-origin-disabled** Disables the requirement for CSRF token to be present in the Stomp headers (default false). Changing the default is useful if it is necessary to allow other origins to make SockJS connections. +[[nsa-websocket-message-broker-authorization-manager-ref]] +* **authorization-manager-ref** Use this `AuthorizationManager` instance; when set, `use-authorization-manager` is ignored and assumed to be `true` + +[[nsa-websocket-message-broker-use-authorization-manager]] +* **use-authorization-manager** Uses legacy `SecurityMetadataSource` API instead of `AuthorizationManager` API (default false). + [[nsa-websocket-message-broker-children]] === Child Elements of diff --git a/docs/modules/ROOT/pages/servlet/integrations/websocket.adoc b/docs/modules/ROOT/pages/servlet/integrations/websocket.adoc index c4d12e7882..cadcc124ae 100644 --- a/docs/modules/ROOT/pages/servlet/integrations/websocket.adoc +++ b/docs/modules/ROOT/pages/servlet/integrations/websocket.adoc @@ -11,23 +11,39 @@ This is because the format is unknown, and there is https://docs.spring.io/sprin Additionally, JSR-356 does not provide a way to intercept messages, so security would be invasive. **** +[[websocket-authentication]] +== WebSocket Authentication + +WebSockets reuse the same authentication information that is found in the HTTP request when the WebSocket connection was made. +This means that the `Principal` on the `HttpServletRequest` will be handed off to WebSockets. +If you are using Spring Security, the `Principal` on the `HttpServletRequest` is overridden automatically. + +More concretely, to ensure a user has authenticated to your WebSocket application, all that is necessary is to ensure that you setup Spring Security to authenticate your HTTP based web application. + [[websocket-configuration]] -== WebSocket Configuration +== WebSocket Authorization Spring Security 4.0 has introduced authorization support for WebSockets through the Spring Messaging abstraction. -To configure authorization by using Java Configuration, extend the `AbstractSecurityWebSocketMessageBrokerConfigurer` and configure the `MessageSecurityMetadataSourceRegistry`: + +In Spring Security 5.8, this support has been refreshed to use the `AuthorizationManager` API. + +To configure authorization using Java Configuration, simply include the `@EnableWebSocketSecurity` annotation and publish an `AuthorizationManager>` bean or in XML use the `use-authorization-manager` attribute. +One way to do this is by using the `AuthorizationManagerMessageMatcherRegistry` to specify endpoint patterns like so: ==== .Java [source,java,role="primary"] ---- @Configuration -public class WebSocketSecurityConfig - extends AbstractSecurityWebSocketMessageBrokerConfigurer { // <1> <2> +@EnableWebSocketSecurity // <1> <2> +public class WebSocketSecurityConfig { - protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) { + @Bean + AuthorizationManager> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) { messages .simpDestMatchers("/user/**").authenticated() // <3> + + return messages.build(); } } ---- @@ -36,9 +52,12 @@ public class WebSocketSecurityConfig [source,kotlin,role="secondary"] ---- @Configuration -open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() { // <1> <2> - override fun configureInbound(messages: MessageSecurityMetadataSourceRegistry) { +@EnableWebSocketSecurity // <1> <2> +open class WebSocketSecurityConfig { // <1> <2> + @Bean + fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager> { messages.simpDestMatchers("/user/**").authenticated() // <3> + return messages.build() } } ---- @@ -53,49 +72,36 @@ A comparable XML based configuration looks like the following: ==== [source,xml] ---- - - - + + ---- +==== + +This will ensure that: + <1> Any inbound CONNECT message requires a valid CSRF token to enforce <> <2> The SecurityContextHolder is populated with the user within the simpUser header attribute for any inbound request. <3> Our messages require the proper authorization. Specifically, any inbound message that starts with "/user/" will require ROLE_USER. Additional details on authorization can be found in <> ==== -[[websocket-authentication]] -== WebSocket Authentication +=== Custom Authorization -WebSockets reuse the same authentication information that is found in the HTTP request when the WebSocket connection was made. -This means that the `Principal` on the `HttpServletRequest` is handed off to WebSockets. -If you use Spring Security, the `Principal` on the `HttpServletRequest` is overridden automatically. - -More concretely, to ensure a user has authenticated to your WebSocket application, all you need to do is ensure that you set up Spring Security to authenticate your HTTP based web application. - -[[websocket-authorization]] -== WebSocket Authorization - -Spring Security 4.0 has introduced authorization support for WebSockets through the Spring Messaging abstraction. -To configure authorization by using Java configuration, extend the `AbstractSecurityWebSocketMessageBrokerConfigurer` and configure the `MessageSecurityMetadataSourceRegistry`: +When using `AuthorizationManager`, customization is quite simple. +For example, you can publish an `AuthorizationManager` that requires that all messages have a role of "USER" using `AuthorityAuthorizationManager`, as seen below: ==== .Java [source,java,role="primary"] ---- @Configuration -public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer { - - @Override - protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) { - messages - .nullDestMatcher().authenticated() // <1> - .simpSubscribeDestMatchers("/user/queue/errors").permitAll() // <2> - .simpDestMatchers("/app/**").hasRole("USER") // <3> - .simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") // <4> - .simpTypeMatchers(MESSAGE, SUBSCRIBE).denyAll() // <5> - .anyMessage().denyAll(); // <6> +@EnableWebSocketSecurity // <1> <2> +public class WebSocketSecurityConfig { + @Bean + AuthorizationManager> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) { + return AuthorityAuthorizationManager.hasRole("USER"); } } ---- @@ -104,8 +110,54 @@ public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBro [source,kotlin,role="secondary"] ---- @Configuration -open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() { - override fun configureInbound(messages: MessageSecurityMetadataSourceRegistry) { +@EnableWebSocketSecurity // <1> <2> +open class WebSocketSecurityConfig { + @Bean + fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager> { + return AuthorityAuthorizationManager.hasRole("USER") // <3> + } +} +---- + +.Xml +[source,xml,role="secondary"] +---- + + + +---- +==== + +There are several ways to further match messages, as can be seen in a more advanced example below: + +==== +.Java +[source,java,role="primary"] +---- +@Configuration +public class WebSocketSecurityConfig { + + @Bean + public AuthorizationManager> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) { + messages + .nullDestMatcher().authenticated() // <1> + .simpSubscribeDestMatchers("/user/queue/errors").permitAll() // <2> + .simpDestMatchers("/app/**").hasRole("USER") // <3> + .simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") // <4> + .simpTypeMatchers(MESSAGE, SUBSCRIBE).denyAll() // <5> + .anyMessage().denyAll(); // <6> + + return messages.build(); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Configuration +open class WebSocketSecurityConfig { + fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager { messages .nullDestMatcher().authenticated() // <1> .simpSubscribeDestMatchers("/user/queue/errors").permitAll() // <2> @@ -113,24 +165,16 @@ open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfi .simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") // <4> .simpTypeMatchers(MESSAGE, SUBSCRIBE).denyAll() // <5> .anyMessage().denyAll() // <6> + + return messages.build(); } } ---- -<1> Any message without a destination (i.e. anything other than Message type of MESSAGE or SUBSCRIBE) will require the user to be authenticated -<2> Anyone can subscribe to /user/queue/errors -<3> Any message that has a destination starting with "/app/" will be require the user to have the role ROLE_USER -<4> Any message that starts with "/user/" or "/topic/friends/" that is of type SUBSCRIBE will require ROLE_USER -<5> Any other message of type MESSAGE or SUBSCRIBE is rejected. Due to 6 we do not need this step, but it illustrates how one can match on specific message types. -<6> Any other Message is rejected. This is a good idea to ensure that you do not miss any messages. -==== -Spring Security also provides xref:servlet/appendix/namespace/websocket.adoc#nsa-websocket-security[XML Namespace] support for securing WebSockets. -A comparable XML based configuration looks like the following: - -==== -[source,xml] +.Xml +[source,kotlin,role="secondary"] ---- - + @@ -140,8 +184,8 @@ A comparable XML based configuration looks like the following: - - + + @@ -150,13 +194,16 @@ A comparable XML based configuration looks like the following: ---- -<1> Any message of type CONNECT, UNSUBSCRIBE, or DISCONNECT will require the user to be authenticated +==== + +This will ensure that: + +<1> Any message without a destination (i.e. anything other than Message type of MESSAGE or SUBSCRIBE) will require the user to be authenticated <2> Anyone can subscribe to /user/queue/errors <3> Any message that has a destination starting with "/app/" will be require the user to have the role ROLE_USER <4> Any message that starts with "/user/" or "/topic/friends/" that is of type SUBSCRIBE will require ROLE_USER <5> Any other message of type MESSAGE or SUBSCRIBE is rejected. Due to 6 we do not need this step, but it illustrates how one can match on specific message types. -<6> Any other message with a destination is rejected. This is a good idea to ensure that you do not miss any messages. -==== +<6> Any other Message is rejected. This is a good idea to ensure that you do not miss any messages. [[websocket-authorization-notes]] === WebSocket Authorization Notes @@ -306,9 +353,65 @@ stompClient.connect(headers, function(frame) { [[websocket-sameorigin-disable]] === Disable CSRF within WebSockets +NOTE: At this point, CSRF is not configurable when using `@EnableWebSocketSecurity`, though this will likely be added in a future release. -If you want to let other domains access your site, you can disable Spring Security's protection. -For example, in Java configuration you can use the following: +To disable CSRF, instead of using `@EnableWebSocketSecurity`, you can use XML support or add the Spring Security components yourself, like so: + +==== +.Java +[source,java,role="primary"] +---- +@Configuration +public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(new AuthenticationPrincipalArgumentResolver()); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + AuthorizationManager> myAuthorizationRules = AuthenticatedAuthorizationManager.authenticated(); + AuthorizationChannelInterceptor authz = new AuthorizationChannelInterceptor(myAuthorizationRules); + AuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(this.context); + authz.setAuthorizationEventPublisher(publisher); + registration.interceptors(new SecurityContextChannelInterceptor(), authz); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Configuration +open class WebSocketSecurityConfig : WebSocketMessageBrokerConfigurer { + @Override + override fun addArgumentResolvers(argumentResolvers: List) { + argumentResolvers.add(AuthenticationPrincipalArgumentResolver()) + } + + @Override + override fun configureClientInboundChannel(registration: ChannelRegistration) { + var myAuthorizationRules: AuthorizationManager> = AuthenticatedAuthorizationManager.authenticated() + var authz: AuthorizationChannelInterceptor = AuthorizationChannelInterceptor(myAuthorizationRules) + var publisher: AuthorizationEventPublisher = SpringAuthorizationEventPublisher(this.context) + authz.setAuthorizationEventPublisher(publisher) + registration.interceptors(SecurityContextChannelInterceptor(), authz) + } +} +---- + +.Xml +[source,xml,role="secondary"] +---- + + + +---- +==== + +On the other hand, if you are using the <> and you want to allow other domains to access your site, you can disable Spring Security's protection. +For example, in Java Configuration you can use the following: ==== .Java @@ -341,6 +444,39 @@ open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfi ---- ==== +[[websocket-expression-handler]] +=== Custom Expression Handler + +At times, there may be value in customizing how the `access` expressions are handled defined in your `intercept-message` XML elements. +To do this, you can create a class of type `SecurityExpressionHandler>` and refer to it in your XML definition like so: + +[source,xml] +---- + + + ... + + + +---- + +If you are migrating from a legacy usage of `websocket-message-broker` that implements a `SecurityExpressionHandler>`, you can: + 1. Additionally implement the `createEvaluationContext(Supplier, Message)` method and then + 2. Wrap that value in a `MessageAuthorizationContextSecurityExpressionHandler` like so: + +[source,xml] +---- + + + ... + + + + + + + +---- [[websocket-sockjs]] == Working with SockJS @@ -516,4 +652,47 @@ If we use XML-based configuration, we can use thexref:servlet/appendix/namespace ---- + +[[legacy-websocket-configuration]] +== Legacy WebSocket Configuration + +Before Spring Security 5.8, the way to configure messaging authorization using Java Configuration, was to extend the `AbstractSecurityWebSocketMessageBrokerConfigurer` and configure the `MessageSecurityMetadataSourceRegistry`. +For example: + ==== +.Java +[source,java,role="primary"] +---- +@Configuration +public class WebSocketSecurityConfig + extends AbstractSecurityWebSocketMessageBrokerConfigurer { // <1> <2> + + protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) { + messages + .simpDestMatchers("/user/**").authenticated() // <3> + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Configuration +open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() { // <1> <2> + override fun configureInbound(messages: MessageSecurityMetadataSourceRegistry) { + messages.simpDestMatchers("/user/**").authenticated() // <3> + } +} +---- +==== + +This will ensure that: + +<1> Any inbound CONNECT message requires a valid CSRF token to enforce <> +<2> The SecurityContextHolder is populated with the user within the simpUser header attribute for any inbound request. +<3> Our messages require the proper authorization. Specifically, any inbound message that starts with "/user/" will require ROLE_USER. Additional details on authorization can be found in <> + +Using the legacy configuration is helpful in the event that you have a custom `SecurityExpressionHandler` that extends `AbstractSecurityExpressionHandler` and overrides `createEvaluationContextInternal` or `createSecurityExpressionRoot`. +In order to defer `Authorization` lookup, the new `AuthorizationManager` API does not invoke these when evaluating expressions. + +If you are using XML, you can use the legacy APIs simply by not using the `use-authorization-manager` element or setting it to `false`. 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/MessageAuthorizationContextSecurityExpressionHandler.java b/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageAuthorizationContextSecurityExpressionHandler.java new file mode 100644 index 0000000000..934a9d96ff --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/access/expression/MessageAuthorizationContextSecurityExpressionHandler.java @@ -0,0 +1,74 @@ +/* + * 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.expression; + +import java.util.Map; +import java.util.function.Supplier; + +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.messaging.Message; +import org.springframework.security.access.expression.SecurityExpressionHandler; +import org.springframework.security.core.Authentication; +import org.springframework.security.messaging.access.intercept.MessageAuthorizationContext; + +/** + * An expression handler for {@link MessageAuthorizationContext}. + * + * @author Josh Cummings + * @since 5.8 + */ +public final class MessageAuthorizationContextSecurityExpressionHandler + implements SecurityExpressionHandler> { + + private final SecurityExpressionHandler> delegate; + + @SuppressWarnings("rawtypes") + public MessageAuthorizationContextSecurityExpressionHandler() { + this(new DefaultMessageSecurityExpressionHandler()); + } + + public MessageAuthorizationContextSecurityExpressionHandler( + SecurityExpressionHandler> expressionHandler) { + this.delegate = expressionHandler; + } + + @Override + public ExpressionParser getExpressionParser() { + return this.delegate.getExpressionParser(); + } + + @Override + public EvaluationContext createEvaluationContext(Authentication authentication, + MessageAuthorizationContext message) { + return createEvaluationContext(() -> authentication, message); + } + + @Override + public EvaluationContext createEvaluationContext(Supplier authentication, + MessageAuthorizationContext message) { + EvaluationContext context = this.delegate.createEvaluationContext(authentication, message.getMessage()); + Map variables = message.getVariables(); + if (variables != null) { + for (Map.Entry entry : variables.entrySet()) { + context.setVariable(entry.getKey(), entry.getValue()); + } + } + return context; + } + +} 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..5ec879f9cc --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptor.java @@ -0,0 +1,105 @@ +/* + * 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.8 + */ +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 = new NoopAuthorizationEventPublisher(); + + /** + * 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; + } + + private static class NoopAuthorizationEventPublisher implements AuthorizationEventPublisher { + + @Override + public void publishAuthorizationEvent(Supplier authentication, T object, + AuthorizationDecision decision) { + + } + + } + +} 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..c47b64590f --- /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 jakarta.servlet.http.HttpServletRequest; + +import org.springframework.messaging.Message; + +/** + * An {@link Message} authorization context. + * + * @author Josh Cummings + * @since 5.8 + */ +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..57b06c008e --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManager.java @@ -0,0 +1,429 @@ +/* + * 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.messaging.simp.SimpMessageType; +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.core.Authentication; +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; + +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 (Entry>> 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 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<>(); + + private Supplier pathMatcher = () -> new AntPathMatcher(); + + public Builder() { + } + + /** + * Maps any {@link Message} to a security expression. + * @return the Expression to associate + */ + public Builder.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 Builder.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 Builder.Constraint} associated to the matchers. + */ + public Builder.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 Builder.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 Builder.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 Builder.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 Builder.Constraint} that is associated to the + * {@link MessageMatcher} + */ + private Builder.Constraint simpDestMatchers(SimpMessageType type, String... patterns) { + List> matchers = new ArrayList<>(patterns.length); + for (String pattern : patterns) { + Supplier> supplier = new Builder.PathMatcherMessageMatcherBuilder(pattern, type); + MessageMatcher matcher = new Builder.SupplierMessageMatcher(supplier); + matchers.add(matcher); + } + return new Builder.Constraint(matchers); + } + + /** + * The {@link PathMatcher} to be used with the + * {@link Builder#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 Builder} for further customization. + */ + public Builder simpDestPathMatcher(PathMatcher pathMatcher) { + Assert.notNull(pathMatcher, "pathMatcher cannot be null"); + this.pathMatcher = () -> pathMatcher; + return this; + } + + /** + * The {@link PathMatcher} to be used with the + * {@link Builder#simpDestMatchers(String...)}. Use this method to delay the + * computation or lookup of the {@link PathMatcher}. + * @param pathMatcher the {@link PathMatcher} to use. Cannot be null. + * @return the {@link Builder} for further customization. + */ + public Builder simpDestPathMatcher(Supplier 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 Builder.Constraint} that is associated to the + * {@link MessageMatcher} instances + */ + public Builder.Constraint matchers(MessageMatcher... matchers) { + List> builders = new ArrayList<>(matchers.length); + for (MessageMatcher matcher : matchers) { + builders.add(matcher); + } + return new Builder.Constraint(builders); + } + + public AuthorizationManager> build() { + return new MessageMatcherDelegatingAuthorizationManager(this.mappings); + } + + /** + * 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 Builder} for further customization + */ + public Builder 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 Builder} for further customization + */ + public Builder 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 Builder} for further customization + */ + public Builder 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 Builder} for further customization + */ + public Builder hasAnyAuthority(String... authorities) { + return access(AuthorityAuthorizationManager.hasAnyAuthority(authorities)); + } + + /** + * Specify that Messages are allowed by anyone. + * @return the {@link Builder} for further customization + */ + public Builder permitAll() { + return access((authentication, context) -> new AuthorizationDecision(true)); + } + + /** + * Specify that Messages are not allowed by anyone. + * @return the {@link Builder} for further customization + */ + public Builder denyAll() { + return access((authorization, context) -> new AuthorizationDecision(false)); + } + + /** + * Specify that Messages are allowed by any authenticated user. + * @return the {@link Builder} for further customization + */ + public Builder 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 Builder} for further customization + */ + public Builder access(AuthorizationManager> authorizationManager) { + for (MessageMatcher messageMatcher : this.messageMatchers) { + Builder.this.mappings.add(new Entry<>(messageMatcher, authorizationManager)); + } + return Builder.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() { + return Builder.this.pathMatcher.get(); + } + + @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"); + } + + } + + } + + private static final class Entry { + + private final MessageMatcher messageMatcher; + + private final T entry; + + Entry(MessageMatcher requestMatcher, T entry) { + this.messageMatcher = requestMatcher; + this.entry = entry; + } + + MessageMatcher getMessageMatcher() { + return this.messageMatcher; + } + + T getEntry() { + return this.entry; + } + + } + +} 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/test/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptorTests.java b/messaging/src/test/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptorTests.java new file mode 100644 index 0000000000..60528995c9 --- /dev/null +++ b/messaging/src/test/java/org/springframework/security/messaging/access/intercept/AuthorizationChannelInterceptorTests.java @@ -0,0 +1,109 @@ +/* + * 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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link AuthorizationChannelInterceptor} + */ +@ExtendWith(MockitoExtension.class) +public class AuthorizationChannelInterceptorTests { + + @Mock + Message message; + + @Mock + MessageChannel channel; + + @Mock + AuthorizationManager> authorizationManager; + + @Mock + AuthorizationEventPublisher eventPublisher; + + Authentication originalAuth; + + AuthorizationChannelInterceptor interceptor; + + @BeforeEach + public void setup() { + this.interceptor = new AuthorizationChannelInterceptor(this.authorizationManager); + this.originalAuth = new TestingAuthenticationToken("user", "pass", "ROLE_USER"); + SecurityContextHolder.getContext().setAuthentication(this.originalAuth); + } + + @AfterEach + public void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + public void constructorWhenAuthorizationManagerNullThenIllegalArgument() { + assertThatIllegalArgumentException().isThrownBy(() -> new AuthorizationChannelInterceptor(null)); + } + + @Test + public void preSendWhenAllowThenSameMessage() { + given(this.authorizationManager.check(any(), any())).willReturn(new AuthorizationDecision(true)); + assertThat(this.interceptor.preSend(this.message, this.channel)).isSameAs(this.message); + } + + @Test + public void preSendWhenDenyThenException() { + given(this.authorizationManager.check(any(), any())).willReturn(new AuthorizationDecision(false)); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(() -> this.interceptor.preSend(this.message, this.channel)); + } + + @Test + public void setEventPublisherWhenNullThenException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> this.interceptor.setAuthorizationEventPublisher(null)); + } + + @Test + public void preSendWhenAuthorizationEventPublisherThenPublishes() { + this.interceptor.setAuthorizationEventPublisher(this.eventPublisher); + given(this.authorizationManager.check(any(), any())).willReturn(new AuthorizationDecision(true)); + this.interceptor.preSend(this.message, this.channel); + verify(this.eventPublisher).publishAuthorizationEvent(any(), any(), any()); + } + +}