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 index 62fc8d80079..0cb3259483c 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -21,6 +21,7 @@ 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.security.messaging.util.matcher.MessageMatcherFactory; import org.springframework.util.AntPathMatcher; final class MessageMatcherAuthorizationManagerConfiguration { @@ -29,6 +30,7 @@ final class MessageMatcherAuthorizationManagerConfiguration { @Scope("prototype") MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder( ApplicationContext context) { + MessageMatcherFactory.setApplicationContext(context); return MessageMatcherDelegatingAuthorizationManager.builder() .simpDestPathMatcher( () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) diff --git a/config/src/main/java/org/springframework/security/config/web/messaging/PathPatternMessageMatcherBuilderFactoryBean.java b/config/src/main/java/org/springframework/security/config/web/messaging/PathPatternMessageMatcherBuilderFactoryBean.java new file mode 100644 index 00000000000..0d14994a6e6 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/messaging/PathPatternMessageMatcherBuilderFactoryBean.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2025 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.web.messaging; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +import org.springframework.security.messaging.util.matcher.PathPatternMessageMatcher; + +/** + * Use this factory bean to configure the {@link PathPatternMessageMatcher.Builder} bean + * used to create request matchers in {@link MessageMatcherDelegatingAuthorizationManager} + * and other parts of the DSL. + * + * @author Pat McCusker + * @since 6.5 + */ +public final class PathPatternMessageMatcherBuilderFactoryBean + implements FactoryBean { + + @Override + public PathPatternMessageMatcher.Builder getObject() throws Exception { + return PathPatternMessageMatcher.withDefaults(); + } + + @Override + public Class getObjectType() { + return PathPatternMessageMatcher.Builder.class; + } + +} 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 index 15154dc6648..4a2b3de56f6 100644 --- 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 @@ -18,7 +18,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.function.Supplier; import org.apache.commons.logging.Log; @@ -34,10 +33,13 @@ import org.springframework.security.authorization.SingleResultAuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.security.messaging.util.matcher.MessageMatcher; +import org.springframework.security.messaging.util.matcher.MessageMatcherFactory; +import org.springframework.security.messaging.util.matcher.PathPatternMessageMatcher; 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.CollectionUtils; import org.springframework.util.PathMatcher; import org.springframework.util.function.SingletonSupplier; @@ -85,16 +87,15 @@ public AuthorizationDecision check(Supplier authentication, Mess } private MessageAuthorizationContext authorizationContext(MessageMatcher matcher, Message message) { - if (!matcher.matches((Message) message)) { + MessageMatcher.MatchResult matchResult = matcher.matcher((Message) message); + if (!matchResult.isMatch()) { return null; } - if (matcher instanceof SimpDestinationMessageMatcher simp) { - return new MessageAuthorizationContext<>(message, simp.extractPathVariables(message)); - } - if (matcher instanceof Builder.LazySimpDestinationMessageMatcher) { - Builder.LazySimpDestinationMessageMatcher path = (Builder.LazySimpDestinationMessageMatcher) matcher; - return new MessageAuthorizationContext<>(message, path.extractPathVariables(message)); + + if (!CollectionUtils.isEmpty(matchResult.getVariables())) { + return new MessageAuthorizationContext<>(message, matchResult.getVariables()); } + return new MessageAuthorizationContext<>(message); } @@ -113,6 +114,7 @@ public static final class Builder { private final List>>> mappings = new ArrayList<>(); + @Deprecated private Supplier pathMatcher = AntPathMatcher::new; public Builder() { @@ -133,11 +135,11 @@ public Builder.Constraint anyMessage() { * @return the Expression to associate */ public Builder.Constraint nullDestMatcher() { - return matchers(SimpDestinationMessageMatcher.NULL_DESTINATION_MATCHER); + return matchers(PathPatternMessageMatcher.NULL_DESTINATION_MATCHER); } /** - * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances. + * Maps a {@link List} of {@link SimpMessageTypeMatcher} instances. * @param typesToMatch the {@link SimpMessageType} instance to match on * @return the {@link Builder.Constraint} associated to the matchers. */ @@ -151,56 +153,58 @@ public Builder.Constraint simpTypeMatchers(SimpMessageType... typesToMatch) { } /** - * 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. + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} (or + * {@link PathPatternMessageMatcher} if the application has configured a + * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean}) + * 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 {@code MessageMatcher}s 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. + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} (or + * {@link PathPatternMessageMatcher} if the application has configured a + * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean}) + * 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 {@code MessageMatcher}s 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. + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} (or + * {@link PathPatternMessageMatcher} if the application has configured a + * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean}) + * 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 {@code MessageMatcher}s 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. + * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances, or + * {@link PathPatternMessageMatcher} if the application has configured a + * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean}. + * 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. + * @param patterns the patterns to create {@code MessageMatcher}s 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) { - MessageMatcher matcher = new LazySimpDestinationMessageMatcher(pattern, type); + MessageMatcher matcher = MessageMatcherFactory.usesPathPatterns() + ? MessageMatcherFactory.matcher(type, pattern) + : new LazySimpDestinationMessageMatcher(pattern, type); matchers.add(matcher); } return new Builder.Constraint(matchers); @@ -212,7 +216,9 @@ private Builder.Constraint simpDestMatchers(SimpMessageType type, String... patt * constructor of {@link AntPathMatcher}. * @param pathMatcher the {@link PathMatcher} to use. Cannot be null. * @return the {@link Builder} for further customization. + * @deprecated */ + @Deprecated public Builder simpDestPathMatcher(PathMatcher pathMatcher) { Assert.notNull(pathMatcher, "pathMatcher cannot be null"); this.pathMatcher = () -> pathMatcher; @@ -225,7 +231,9 @@ public Builder simpDestPathMatcher(PathMatcher pathMatcher) { * computation or lookup of the {@link PathMatcher}. * @param pathMatcher the {@link PathMatcher} to use. Cannot be null. * @return the {@link Builder} for further customization. + * @deprecated */ + @Deprecated public Builder simpDestPathMatcher(Supplier pathMatcher) { Assert.notNull(pathMatcher, "pathMatcher cannot be null"); this.pathMatcher = pathMatcher; @@ -382,6 +390,7 @@ public Builder access(AuthorizationManager> autho } + @Deprecated private final class LazySimpDestinationMessageMatcher implements MessageMatcher { private final Supplier delegate; @@ -407,8 +416,9 @@ public boolean matches(Message message) { return this.delegate.get().matches(message); } - Map extractPathVariables(Message message) { - return this.delegate.get().extractPathVariables(message); + @Override + public MatchResult matcher(Message message) { + return this.delegate.get().matcher(message); } } diff --git a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcher.java b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcher.java index 418be34ac00..d20dc91015f 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcher.java +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,9 @@ package org.springframework.security.messaging.util.matcher; +import java.util.Collections; +import java.util.Map; + import org.springframework.messaging.Message; /** @@ -50,4 +53,77 @@ public String toString() { */ boolean matches(Message message); + /** + * Returns a {@link MatchResult} for this {@code MessageMatcher}. The default + * implementation returns {@link Collections#emptyMap()} when + * {@link MatchResult#getVariables()} is invoked. + * @return the {@code MatchResult} from comparing this {@code MessageMatcher} against + * the {@code Message} + * @since 6.5 + */ + default MatchResult matcher(Message message) { + boolean match = matches(message); + return new MatchResult(match, Collections.emptyMap()); + } + + /** + * The result of matching against a {@code Message} contains the status, true or + * false, of the match and if present, any variables extracted from the match + * + * @since 6.5 + */ + class MatchResult { + + private final boolean match; + + private final Map variables; + + MatchResult(boolean match, Map variables) { + this.match = match; + this.variables = variables; + } + + /** + * Return whether the comparison against the {@code Message} produced a successful + * match + */ + public boolean isMatch() { + return this.match; + } + + /** + * 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; + } + + /** + * Creates an instance of {@link MatchResult} that is a match with no variables + */ + public static MatchResult match() { + return new MatchResult(true, Collections.emptyMap()); + } + + /** + * Creates an instance of {@link MatchResult} that is a match with the specified + * variables + */ + public static MatchResult match(Map variables) { + return new MatchResult(true, variables); + } + + /** + * Creates an instance of {@link MatchResult} that is not a match. + * @return a {@code MatchResult} with match set to false + */ + public static MatchResult notMatch() { + return new MatchResult(false, Collections.emptyMap()); + } + + } + } diff --git a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherFactory.java b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherFactory.java new file mode 100644 index 00000000000..d494b31b4c2 --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherFactory.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.messaging.util.matcher; + +import org.springframework.context.ApplicationContext; +import org.springframework.messaging.simp.SimpMessageType; + +/** + * This utility exists only to facilitate applications opting into using path patterns in + * the Message Security DSL. It is for internal use only. + * + * @deprecated + */ +@Deprecated(forRemoval = true) +public final class MessageMatcherFactory { + + private static PathPatternMessageMatcher.Builder builder; + + public static void setApplicationContext(ApplicationContext context) { + builder = context.getBeanProvider(PathPatternMessageMatcher.Builder.class).getIfUnique(); + } + + public static boolean usesPathPatterns() { + return builder != null; + } + + public static MessageMatcher matcher(String destination) { + return matcher(null, destination); + } + + public static MessageMatcher matcher(SimpMessageType type, String destination) { + return builder.matcher(type, destination); + } + + private MessageMatcherFactory() { + } + +} diff --git a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcher.java b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcher.java new file mode 100644 index 00000000000..ca8509eedab --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcher.java @@ -0,0 +1,202 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.messaging.util.matcher; + +import java.util.Collections; + +import org.springframework.http.server.PathContainer; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.security.messaging.access.intercept.MessageAuthorizationContext; +import org.springframework.util.Assert; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Match {@link Message}s based on the message destination pattern using a + * {@link PathPattern}. There is also support for optionally matching on a specified + * {@link SimpMessageType}. + * + * @author Pat McCusker + * @since 6.5 + */ +public final class PathPatternMessageMatcher implements MessageMatcher { + + public static final MessageMatcher NULL_DESTINATION_MATCHER = (message) -> getDestination(message) == null; + + private final PathPattern pattern; + + private final PathContainer.Options options; + + /** + * The {@link MessageMatcher} that determines if the type matches. If the type was + * null, this matcher will match every Message. + */ + private MessageMatcher messageTypeMatcher = ANY_MESSAGE; + + private PathPatternMessageMatcher(PathPattern pattern, PathContainer.Options options) { + this.options = options; + this.pattern = pattern; + } + + /** + * Initialize this builder with the {@link PathPatternParser#defaultInstance} that is + * configured with the + * {@link org.springframework.http.server.PathContainer.Options#HTTP_PATH} separator + */ + public static Builder withDefaults() { + return new Builder(PathPatternParser.defaultInstance); + } + + /** + * Initialize this builder with the provided {@link PathPatternParser} + */ + public static Builder withPathPatternParser(PathPatternParser parser) { + return new Builder(parser); + } + + void setMessageTypeMatcher(MessageMatcher messageTypeMatcher) { + this.messageTypeMatcher = messageTypeMatcher; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean matches(Message message) { + return matcher(message).isMatch(); + } + + /** + * Extract the path variables from the {@link Message} destination if the path is a + * match, otherwise the {@link MatchResult#getVariables()} returns a + * {@link Collections#emptyMap()} + * @param message the message whose path variables to extract. + * @return a {@code MatchResult} of the path variables and values. + */ + @Override + public MatchResult matcher(Message message) { + if (!this.messageTypeMatcher.matches(message)) { + return MatchResult.notMatch(); + } + + String destination = getDestination(message); + if (destination == null) { + return MatchResult.notMatch(); + } + + PathContainer destinationPathContainer = PathContainer.parsePath(destination, this.options); + PathPattern.PathMatchInfo pathMatchInfo = this.pattern.matchAndExtract(destinationPathContainer); + + return (pathMatchInfo != null) ? MatchResult.match(pathMatchInfo.getUriVariables()) : MatchResult.notMatch(); + } + + private static String getDestination(Message message) { + return SimpMessageHeaderAccessor.getDestination(message.getHeaders()); + } + + /** + * A builder for specifying various elements of a message for the purpose of creating + * a {@link PathPatternMessageMatcher}. + */ + public static class Builder { + + private final PathPatternParser parser; + + Builder(PathPatternParser parser) { + this.parser = parser; + } + + /** + * Match messages having this destination pattern. + * + *

+ * Path patterns always start with a slash and may contain placeholders. They can + * also be followed by {@code /**} to signify all URIs under a given path. + * + *

+ * The following are valid patterns and their meaning + *

    + *
  • {@code /path} - match exactly and only `/path`
  • + *
  • {@code /path/**} - match `/path` and any of its descendents
  • + *
  • {@code /path/{value}/**} - match `/path/subdirectory` and any of its + * descendents, capturing the value of the subdirectory in + * {@link MessageAuthorizationContext#getVariables()}
  • + *
+ * + *

+ * A more comprehensive list can be found at {@link PathPattern}. + * + *

+ * A dot-based message pattern is also supported when configuring a + * {@link PathPatternParser} using + * {@link PathPatternMessageMatcher#withPathPatternParser} + * @param pattern the destination pattern to match + * @return the {@link PathPatternMessageMatcher.Builder} for more configuration + */ + public PathPatternMessageMatcher matcher(String pattern) { + return matcher(null, pattern); + } + + /** + * Match messages having this type and destination pattern. + * + *

+ * When the message {@code type} is null, then the matcher does not consider the + * message type + * + *

+ * Path patterns always start with a slash and may contain placeholders. They can + * also be followed by {@code /**} to signify all URIs under a given path. + * + *

+ * The following are valid patterns and their meaning + *

    + *
  • {@code /path} - match exactly and only `/path`
  • + *
  • {@code /path/**} - match `/path` and any of its descendents
  • + *
  • {@code /path/{value}/**} - match `/path/subdirectory` and any of its + * descendents, capturing the value of the subdirectory in + * {@link MessageAuthorizationContext#getVariables()}
  • + *
+ * + *

+ * A more comprehensive list can be found at {@link PathPattern}. + * + *

+ * A dot-based message pattern is also supported when configuring a + * {@link PathPatternParser} using + * {@link PathPatternMessageMatcher#withPathPatternParser} + * @param type the message type to match + * @param pattern the destination pattern to match + * @return the {@link PathPatternMessageMatcher.Builder} for more configuration + */ + public PathPatternMessageMatcher matcher(@Nullable SimpMessageType type, String pattern) { + Assert.notNull(pattern, "pattern must not be null"); + PathPattern pathPattern = this.parser.parse(pattern); + PathPatternMessageMatcher matcher = new PathPatternMessageMatcher(pathPattern, + this.parser.getPathOptions()); + if (type != null) { + matcher.setMessageTypeMatcher(new SimpMessageTypeMatcher(type)); + } + return matcher; + } + + } + +} diff --git a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcher.java b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcher.java index d4ae0e15d63..17611198259 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcher.java +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2025 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,9 @@ * * @author Rob Winch * @since 4.0 + * @deprecated use {@link PathPatternMessageMatcher} */ +@Deprecated public final class SimpDestinationMessageMatcher implements MessageMatcher { public static final MessageMatcher NULL_DESTINATION_MATCHER = (message) -> { @@ -123,6 +125,12 @@ public boolean matches(Message message) { return destination != null && this.matcher.match(this.pattern, destination); } + @Override + public MatchResult matcher(Message message) { + boolean match = matches(message); + return (!match) ? MatchResult.notMatch() : MatchResult.match(extractPathVariables(message)); + } + public Map extractPathVariables(Message message) { final String destination = SimpMessageHeaderAccessor.getDestination(message.getHeaders()); return (destination != null) ? this.matcher.extractUriTemplateVariables(this.pattern, destination) diff --git a/messaging/src/test/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManagerTests.java b/messaging/src/test/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManagerTests.java index 29cd3ff5e96..acbb6dff2fc 100644 --- a/messaging/src/test/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManagerTests.java +++ b/messaging/src/test/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManagerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -19,8 +19,15 @@ import java.util.Map; import java.util.function.Supplier; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; @@ -30,6 +37,8 @@ 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.MessageMatcherFactory; +import org.springframework.security.messaging.util.matcher.PathPatternMessageMatcher; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -37,8 +46,21 @@ /** * Tests for {@link MessageMatcherDelegatingAuthorizationManager} */ +@ExtendWith(MockitoExtension.class) public final class MessageMatcherDelegatingAuthorizationManagerTests { + @Mock + private ApplicationContext context; + + @Mock + private ObjectProvider provider; + + @BeforeEach + void setUp() { + Mockito.when(this.context.getBeanProvider(PathPatternMessageMatcher.Builder.class)).thenReturn(this.provider); + MessageMatcherFactory.setApplicationContext(this.context); + } + @Test void checkWhenPermitAllThenPermits() { AuthorizationManager> authorizationManager = builder().anyMessage().permitAll().build(); @@ -111,6 +133,42 @@ void checkWhenSimpDestinationMatchesThenVariablesExtracted() { assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue(); } + @Test + void checkWhenMessageTypeAndPathPatternMatches() { + Mockito.when(this.provider.getIfUnique()).thenReturn(PathPatternMessageMatcher.withDefaults()); + MessageMatcherFactory.setApplicationContext(this.context); + AuthorizationManager> authorizationManager = builder().simpMessageDestMatchers("/destination") + .permitAll() + .simpSubscribeDestMatchers("/destination") + .denyAll() + .anyMessage() + .denyAll() + .build(); + MessageHeaders headers = new MessageHeaders(Map.of(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, + SimpMessageType.MESSAGE, SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination")); + Message message = new GenericMessage<>(new Object(), headers); + assertThat(authorizationManager.authorize(mock(Supplier.class), message).isGranted()).isTrue(); + MessageHeaders headers2 = new MessageHeaders(Map.of(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, + SimpMessageType.SUBSCRIBE, SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination")); + Message message2 = new GenericMessage<>(new Object(), headers2); + assertThat(authorizationManager.check(mock(Supplier.class), message2).isGranted()).isFalse(); + } + + @Test + void checkPatternMismatch() { + Mockito.when(this.provider.getIfUnique()).thenReturn(PathPatternMessageMatcher.withDefaults()); + MessageMatcherFactory.setApplicationContext(this.context); + AuthorizationManager> authorizationManager = builder().simpDestMatchers("/destination/*") + .permitAll() + .anyMessage() + .denyAll() + .build(); + MessageHeaders headers = new MessageHeaders( + Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/sub/asdf")); + Message message = new GenericMessage<>(new Object(), headers); + assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isFalse(); + } + private MessageMatcherDelegatingAuthorizationManager.Builder builder() { return MessageMatcherDelegatingAuthorizationManager.builder(); } diff --git a/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherTests.java b/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherTests.java new file mode 100644 index 00000000000..e776b2dbb53 --- /dev/null +++ b/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.messaging.util.matcher; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.server.PathContainer; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.web.util.pattern.PathPatternParser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +public class PathPatternMessageMatcherTests { + + MessageBuilder messageBuilder; + + PathPatternMessageMatcher matcher; + + @BeforeEach + void setUp() { + this.messageBuilder = MessageBuilder.withPayload("M"); + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/**"); + } + + @Test + void constructorPatternNull() { + assertThatIllegalArgumentException().isThrownBy(() -> PathPatternMessageMatcher.withDefaults().matcher(null)); + } + + @Test + void matchesDoesNotMatchNullDestination() { + assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse(); + } + + @Test + void matchesTrueWithSpecificDestinationPattern() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/destination/1"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1"); + assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue(); + } + + @Test + void matchesFalseWithDifferentDestination() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/nomatch"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1"); + assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse(); + } + + @Test + void matchesTrueWithDotSeparator() { + this.matcher = PathPatternMessageMatcher.withPathPatternParser(dotSeparatedPathParser()) + .matcher("destination.1"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination.1"); + assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue(); + } + + @Test + void matchesFalseWithDotSeparatorAndAdditionalWildcardPathSegment() { + this.matcher = PathPatternMessageMatcher.withPathPatternParser(dotSeparatedPathParser()) + .matcher("/destination/a.*"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/a.b"); + assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue(); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/a.b.c"); + assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse(); + } + + @Test + void matchesFalseWithDifferentMessageType() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher(SimpMessageType.MESSAGE, "/match"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.DISCONNECT); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/match"); + + assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse(); + } + + @Test + void matchesTrueMessageType() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher(SimpMessageType.MESSAGE, "/match"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/match"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.MESSAGE); + assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue(); + } + + @Test + void matchesTrueSubscribeType() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher(SimpMessageType.SUBSCRIBE, "/match"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/match"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.SUBSCRIBE); + assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue(); + } + + @Test + void extractPathVariablesFromDestination() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/topics/{topic}/**"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/topics/someTopic/sub1"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.MESSAGE); + + MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build()); + assertThat(matchResult.isMatch()).isTrue(); + assertThat(matchResult.getVariables()).containsEntry("topic", "someTopic"); + } + + @Test + void extractPathVariablesFromMessageDestinationPath() { + this.matcher = PathPatternMessageMatcher.withPathPatternParser(dotSeparatedPathParser()) + .matcher("destination.{destinationNum}"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination.1"); + MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build()); + assertThat(matchResult.getVariables()).containsEntry("destinationNum", "1"); + } + + @Test + void extractPathVariables_isEmptyWithNullDestination() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/topics/{topic}/**"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.MESSAGE); + + MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build()); + assertThat(matchResult.isMatch()).isFalse(); + assertThat(matchResult.getVariables()).isEmpty(); + } + + @Test + void getUriVariablesIsEmpty_onExtractPathVariables_whenNoMatch() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/nomatch"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1"); + MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build()); + assertThat(matchResult.isMatch()).isFalse(); + assertThat(matchResult.getVariables()).isEmpty(); + } + + private static PathPatternParser dotSeparatedPathParser() { + PathPatternParser parser = new PathPatternParser(); + parser.setPathOptions(PathContainer.Options.MESSAGE_ROUTE); + return parser; + } + +} diff --git a/messaging/src/test/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcherTests.java b/messaging/src/test/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcherTests.java index b13bdab5dc0..c36fe7ac47e 100644 --- a/messaging/src/test/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcherTests.java +++ b/messaging/src/test/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcherTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2025 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,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; public class SimpDestinationMessageMatcherTests { @@ -129,4 +130,12 @@ public void typeConstructorParameterIsTransmitted() { assertThat(this.matcher.getMessageTypeMatcher()).isEqualTo(expectedTypeMatcher); } + @Test + public void extractPathVariablesWhenNoMatchThenIllegalState() { + this.matcher = new SimpDestinationMessageMatcher("/nomatch"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1"); + assertThatIllegalStateException() + .isThrownBy(() -> this.matcher.extractPathVariables(this.messageBuilder.build())); + } + }