From 44665c15480468e1030329b319c0673cfdd9e2c7 Mon Sep 17 00:00:00 2001 From: Pat McCusker Date: Fri, 7 Mar 2025 13:55:51 -0500 Subject: [PATCH 1/3] Add the MatchResult class to MessageMatcher Closes gh-16766 Signed-off-by: Pat McCusker --- .../util/matcher/MessageMatcher.java | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) 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()); + } + + } + } From 7cc87066110cde6d7b28e9e0678609d966ecd69b Mon Sep 17 00:00:00 2001 From: Pat McCusker Date: Fri, 7 Mar 2025 13:57:08 -0500 Subject: [PATCH 2/3] Add PathPatternMessageMatcher Closes gh-16500 Signed-off-by: Pat McCusker --- ...cherAuthorizationManagerConfiguration.java | 4 +- ...MatcherDelegatingAuthorizationManager.java | 84 ++++++---- .../util/matcher/MessageMatcherFactory.java | 46 ++++++ .../matcher/PathPatternMessageMatcher.java | 151 +++++++++++++++++ ...tternMessageMatcherBuilderFactoryBean.java | 54 ++++++ .../SimpDestinationMessageMatcher.java | 4 +- ...erDelegatingAuthorizationManagerTests.java | 78 +++++++-- ...MessageMatcherBuilderFactoryBeanTests.java | 62 +++++++ .../PathPatternMessageMatcherTests.java | 155 ++++++++++++++++++ .../SimpDestinationMessageMatcherTests.java | 11 +- 10 files changed, 597 insertions(+), 52 deletions(-) create mode 100644 messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherFactory.java create mode 100644 messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcher.java create mode 100644 messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java create mode 100644 messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java create mode 100644 messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherTests.java 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/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..372ed1629e1 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 @@ -17,6 +17,7 @@ package org.springframework.security.messaging.access.intercept; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -34,10 +35,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,15 +89,17 @@ 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 (!CollectionUtils.isEmpty(matchResult.getVariables())) { + return new MessageAuthorizationContext<>(message, matchResult.getVariables()); } - if (matcher instanceof Builder.LazySimpDestinationMessageMatcher) { - Builder.LazySimpDestinationMessageMatcher path = (Builder.LazySimpDestinationMessageMatcher) matcher; - return new MessageAuthorizationContext<>(message, path.extractPathVariables(message)); + + if (matcher instanceof Builder.LazySimpDestinationMessageMatcher pathMatcher) { + return new MessageAuthorizationContext<>(message, pathMatcher.extractPathVariables(message)); } return new MessageAuthorizationContext<>(message); } @@ -113,6 +119,7 @@ public static final class Builder { private final List>>> mappings = new ArrayList<>(); + @Deprecated private Supplier pathMatcher = AntPathMatcher::new; public Builder() { @@ -133,11 +140,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 +158,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(pattern, type) + : new LazySimpDestinationMessageMatcher(pattern, type); matchers.add(matcher); } return new Builder.Constraint(matchers); @@ -212,7 +221,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 +236,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; @@ -241,9 +254,7 @@ public Builder simpDestPathMatcher(Supplier pathMatcher) { */ public Builder.Constraint matchers(MessageMatcher... matchers) { List> builders = new ArrayList<>(matchers.length); - for (MessageMatcher matcher : matchers) { - builders.add(matcher); - } + builders.addAll(Arrays.asList(matchers)); return new Builder.Constraint(builders); } @@ -382,6 +393,7 @@ public Builder access(AuthorizationManager> autho } + @Deprecated private final class LazySimpDestinationMessageMatcher implements MessageMatcher { private final Supplier delegate; @@ -421,7 +433,7 @@ private static final class Entry { private final T entry; - Entry(MessageMatcher requestMatcher, T entry) { + Entry(MessageMatcher requestMatcher, T entry) { this.messageMatcher = requestMatcher; this.entry = entry; } 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..aee72edc7ac --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherFactory.java @@ -0,0 +1,46 @@ +/* + * 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; + +@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 builder.matcher(destination); + } + + public static MessageMatcher matcher(String destination, SimpMessageType type) { + return (type != null) ? builder.matcher(destination, type) : builder.matcher(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..81035a74938 --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcher.java @@ -0,0 +1,151 @@ +/* + * 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.messaging.Message; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +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 PathPatternParser parser; + + /** + * 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, PathPatternParser parser) { + this.parser = parser; + 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) { + if (!this.messageTypeMatcher.matches(message)) { + return false; + } + + String destination = getDestination(message); + if (destination == null) { + return false; + } + + PathContainer destinationPathContainer = PathContainer.parsePath(destination, this.parser.getPathOptions()); + return this.pattern.matches(destinationPathContainer); + } + + /** + * 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.parser.getPathOptions()); + 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()); + } + + public static class Builder { + + private final PathPatternParser parser; + + private MessageMatcher messageTypeMatcher = ANY_MESSAGE; + + Builder(PathPatternParser parser) { + this.parser = parser; + } + + public PathPatternMessageMatcher matcher(String pattern) { + Assert.notNull(pattern, "Pattern must not be null"); + PathPattern pathPattern = this.parser.parse(pattern); + PathPatternMessageMatcher matcher = new PathPatternMessageMatcher(pathPattern, this.parser); + if (this.messageTypeMatcher != ANY_MESSAGE) { + matcher.setMessageTypeMatcher(this.messageTypeMatcher); + } + return matcher; + } + + public PathPatternMessageMatcher matcher(String pattern, SimpMessageType type) { + Assert.notNull(type, "Type must not be null"); + this.messageTypeMatcher = new SimpMessageTypeMatcher(type); + + return matcher(pattern); + } + + } + +} diff --git a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java new file mode 100644 index 00000000000..3b7ff09f592 --- /dev/null +++ b/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java @@ -0,0 +1,54 @@ +/* + * 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.beans.factory.FactoryBean; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Use this factory bean to configure the {@link PathPatternMessageMatcher.Builder} bean + * used to create request matchers in + * {@link org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager} + * and other parts of the DSL. + * + * @author Pat McCusker + * @since 6.5 + */ +public class PathPatternMessageMatcherBuilderFactoryBean implements FactoryBean { + + private final PathPatternParser parser; + + public PathPatternMessageMatcherBuilderFactoryBean() { + this(null); + } + + public PathPatternMessageMatcherBuilderFactoryBean(PathPatternParser parser) { + this.parser = parser; + } + + @Override + public PathPatternMessageMatcher.Builder getObject() throws Exception { + return (this.parser != null) ? PathPatternMessageMatcher.withPathPatternParser(this.parser) + : PathPatternMessageMatcher.withDefaults(); + } + + @Override + public Class getObjectType() { + return PathPatternMessageMatcher.Builder.class; + } + +} 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..a78a4e8812b 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) -> { 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..857ec208966 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(); @@ -58,13 +80,13 @@ void checkWhenAnyMessageHasRoleThenRequires() { @Test void checkWhenSimpDestinationMatchesThenUses() { - AuthorizationManager> authorizationManager = builder().simpDestMatchers("destination") + AuthorizationManager> authorizationManager = builder().simpDestMatchers("/destination") .permitAll() .anyMessage() .denyAll() .build(); MessageHeaders headers = new MessageHeaders( - Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination")); + Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination")); Message message = new GenericMessage<>(new Object(), headers); assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue(); } @@ -79,7 +101,7 @@ void checkWhenNullDestinationHeaderMatchesThenUses() { Message message = new GenericMessage<>(new Object()); assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue(); MessageHeaders headers = new MessageHeaders( - Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination")); + Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination")); message = new GenericMessage<>(new Object(), headers); assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isFalse(); } @@ -100,17 +122,53 @@ void checkWhenSimpTypeMatchesThenUses() { // gh-12540 @Test void checkWhenSimpDestinationMatchesThenVariablesExtracted() { - AuthorizationManager> authorizationManager = builder().simpDestMatchers("destination/{id}") + AuthorizationManager> authorizationManager = builder().simpDestMatchers("/destination/*/{id}") .access(variable("id").isEqualTo("3")) .anyMessage() .denyAll() .build(); MessageHeaders headers = new MessageHeaders( - Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination/3")); + Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/sub/3")); Message message = new GenericMessage<>(new Object(), headers); 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(); } @@ -120,13 +178,7 @@ private Builder variable(String name) { } - private static final class Builder { - - private final String name; - - private Builder(String name) { - this.name = name; - } + private record Builder(String name) { AuthorizationManager> isEqualTo(String value) { return (authentication, object) -> { diff --git a/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java b/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java new file mode 100644 index 00000000000..9192183f2ba --- /dev/null +++ b/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java @@ -0,0 +1,62 @@ +/* + * 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.context.support.GenericApplicationContext; +import org.springframework.web.util.pattern.PathPatternParser; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class PathPatternMessageMatcherBuilderFactoryBeanTests { + + GenericApplicationContext context; + + @BeforeEach + void setUp() { + this.context = new GenericApplicationContext(); + } + + @Test + void getObjectWhenDefaultsThenBuilder() throws Exception { + factoryBean().getObject(); + } + + @Test + void getObjectWithCustomParserThenUses() throws Exception { + PathPatternParser parser = mock(PathPatternParser.class); + PathPatternMessageMatcher.Builder builder = factoryBean(parser).getObject(); + + builder.matcher("/path/**"); + verify(parser).parse("/path/**"); + } + + PathPatternMessageMatcherBuilderFactoryBean factoryBean() { + PathPatternMessageMatcherBuilderFactoryBean factoryBean = new PathPatternMessageMatcherBuilderFactoryBean(); + return factoryBean; + } + + PathPatternMessageMatcherBuilderFactoryBean factoryBean(PathPatternParser parser) { + PathPatternMessageMatcherBuilderFactoryBean factoryBean = new PathPatternMessageMatcherBuilderFactoryBean( + parser); + return factoryBean; + } + +} 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..cce440de91d --- /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("/match", SimpMessageType.MESSAGE); + 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 + public void matchesTrueMessageType() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.MESSAGE); + 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 + public void matchesTrueSubscribeType() { + this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.SUBSCRIBE); + 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..eba7bccaadf 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 + void illegalStateExceptionThrown_onExtractPathVariables_whenNoMatch() { + this.matcher = new SimpDestinationMessageMatcher("/nomatch"); + this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1"); + assertThatIllegalStateException() + .isThrownBy(() -> this.matcher.extractPathVariables(this.messageBuilder.build())); + } + } From 949d1cca36de559ac388b4c625ec2390027db4e0 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 18 Mar 2025 15:11:19 -0600 Subject: [PATCH 3/3] PathPatternMessageMatcher Polish Issue gh-16500 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- ...tternMessageMatcherBuilderFactoryBean.java | 24 ++-- ...MatcherDelegatingAuthorizationManager.java | 18 ++- .../util/matcher/MessageMatcherFactory.java | 12 +- .../matcher/PathPatternMessageMatcher.java | 107 +++++++++++++----- .../SimpDestinationMessageMatcher.java | 6 + ...erDelegatingAuthorizationManagerTests.java | 18 ++- ...MessageMatcherBuilderFactoryBeanTests.java | 62 ---------- .../PathPatternMessageMatcherTests.java | 10 +- .../SimpDestinationMessageMatcherTests.java | 2 +- 9 files changed, 127 insertions(+), 132 deletions(-) rename {messaging/src/main/java/org/springframework/security/messaging/util/matcher => config/src/main/java/org/springframework/security/config/web/messaging}/PathPatternMessageMatcherBuilderFactoryBean.java (57%) delete mode 100644 messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java diff --git a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java b/config/src/main/java/org/springframework/security/config/web/messaging/PathPatternMessageMatcherBuilderFactoryBean.java similarity index 57% rename from messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java rename to config/src/main/java/org/springframework/security/config/web/messaging/PathPatternMessageMatcherBuilderFactoryBean.java index 3b7ff09f592..0d14994a6e6 100644 --- a/messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java +++ b/config/src/main/java/org/springframework/security/config/web/messaging/PathPatternMessageMatcherBuilderFactoryBean.java @@ -14,36 +14,26 @@ * limitations under the License. */ -package org.springframework.security.messaging.util.matcher; +package org.springframework.security.config.web.messaging; import org.springframework.beans.factory.FactoryBean; -import org.springframework.web.util.pattern.PathPatternParser; +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 org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager} + * used to create request matchers in {@link MessageMatcherDelegatingAuthorizationManager} * and other parts of the DSL. * * @author Pat McCusker * @since 6.5 */ -public class PathPatternMessageMatcherBuilderFactoryBean implements FactoryBean { - - private final PathPatternParser parser; - - public PathPatternMessageMatcherBuilderFactoryBean() { - this(null); - } - - public PathPatternMessageMatcherBuilderFactoryBean(PathPatternParser parser) { - this.parser = parser; - } +public final class PathPatternMessageMatcherBuilderFactoryBean + implements FactoryBean { @Override public PathPatternMessageMatcher.Builder getObject() throws Exception { - return (this.parser != null) ? PathPatternMessageMatcher.withPathPatternParser(this.parser) - : PathPatternMessageMatcher.withDefaults(); + return PathPatternMessageMatcher.withDefaults(); } @Override 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 372ed1629e1..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 @@ -17,9 +17,7 @@ package org.springframework.security.messaging.access.intercept; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.function.Supplier; import org.apache.commons.logging.Log; @@ -98,9 +96,6 @@ private MessageAuthorizationContext authorizationContext(MessageMatcher ma return new MessageAuthorizationContext<>(message, matchResult.getVariables()); } - if (matcher instanceof Builder.LazySimpDestinationMessageMatcher pathMatcher) { - return new MessageAuthorizationContext<>(message, pathMatcher.extractPathVariables(message)); - } return new MessageAuthorizationContext<>(message); } @@ -208,7 +203,7 @@ private Builder.Constraint simpDestMatchers(SimpMessageType type, String... patt List> matchers = new ArrayList<>(patterns.length); for (String pattern : patterns) { MessageMatcher matcher = MessageMatcherFactory.usesPathPatterns() - ? MessageMatcherFactory.matcher(pattern, type) + ? MessageMatcherFactory.matcher(type, pattern) : new LazySimpDestinationMessageMatcher(pattern, type); matchers.add(matcher); } @@ -254,7 +249,9 @@ public Builder simpDestPathMatcher(Supplier pathMatcher) { */ public Builder.Constraint matchers(MessageMatcher... matchers) { List> builders = new ArrayList<>(matchers.length); - builders.addAll(Arrays.asList(matchers)); + for (MessageMatcher matcher : matchers) { + builders.add(matcher); + } return new Builder.Constraint(builders); } @@ -419,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); } } @@ -433,7 +431,7 @@ private static final class Entry { private final T entry; - Entry(MessageMatcher requestMatcher, T entry) { + Entry(MessageMatcher requestMatcher, T entry) { this.messageMatcher = requestMatcher; this.entry = entry; } 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 index aee72edc7ac..d494b31b4c2 100644 --- 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 @@ -19,6 +19,12 @@ 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 { @@ -33,11 +39,11 @@ public static boolean usesPathPatterns() { } public static MessageMatcher matcher(String destination) { - return builder.matcher(destination); + return matcher(null, destination); } - public static MessageMatcher matcher(String destination, SimpMessageType type) { - return (type != null) ? builder.matcher(destination, type) : builder.matcher(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 index 81035a74938..ca8509eedab 100644 --- 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 @@ -19,9 +19,11 @@ 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; @@ -40,7 +42,7 @@ public final class PathPatternMessageMatcher implements MessageMatcher { private final PathPattern pattern; - private final PathPatternParser parser; + private final PathContainer.Options options; /** * The {@link MessageMatcher} that determines if the type matches. If the type was @@ -48,8 +50,8 @@ public final class PathPatternMessageMatcher implements MessageMatcher { */ private MessageMatcher messageTypeMatcher = ANY_MESSAGE; - private PathPatternMessageMatcher(PathPattern pattern, PathPatternParser parser) { - this.parser = parser; + private PathPatternMessageMatcher(PathPattern pattern, PathContainer.Options options) { + this.options = options; this.pattern = pattern; } @@ -78,17 +80,7 @@ void setMessageTypeMatcher(MessageMatcher messageTypeMatcher) { */ @Override public boolean matches(Message message) { - if (!this.messageTypeMatcher.matches(message)) { - return false; - } - - String destination = getDestination(message); - if (destination == null) { - return false; - } - - PathContainer destinationPathContainer = PathContainer.parsePath(destination, this.parser.getPathOptions()); - return this.pattern.matches(destinationPathContainer); + return matcher(message).isMatch(); } /** @@ -109,7 +101,7 @@ public MatchResult matcher(Message message) { return MatchResult.notMatch(); } - PathContainer destinationPathContainer = PathContainer.parsePath(destination, this.parser.getPathOptions()); + PathContainer destinationPathContainer = PathContainer.parsePath(destination, this.options); PathPattern.PathMatchInfo pathMatchInfo = this.pattern.matchAndExtract(destinationPathContainer); return (pathMatchInfo != null) ? MatchResult.match(pathMatchInfo.getUriVariables()) : MatchResult.notMatch(); @@ -119,33 +111,92 @@ 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; - private MessageMatcher messageTypeMatcher = ANY_MESSAGE; - 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) { - Assert.notNull(pattern, "Pattern must not be null"); + 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); - if (this.messageTypeMatcher != ANY_MESSAGE) { - matcher.setMessageTypeMatcher(this.messageTypeMatcher); + PathPatternMessageMatcher matcher = new PathPatternMessageMatcher(pathPattern, + this.parser.getPathOptions()); + if (type != null) { + matcher.setMessageTypeMatcher(new SimpMessageTypeMatcher(type)); } return matcher; } - public PathPatternMessageMatcher matcher(String pattern, SimpMessageType type) { - Assert.notNull(type, "Type must not be null"); - this.messageTypeMatcher = new SimpMessageTypeMatcher(type); - - return matcher(pattern); - } - } } 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 a78a4e8812b..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 @@ -125,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 857ec208966..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 @@ -80,13 +80,13 @@ void checkWhenAnyMessageHasRoleThenRequires() { @Test void checkWhenSimpDestinationMatchesThenUses() { - AuthorizationManager> authorizationManager = builder().simpDestMatchers("/destination") + AuthorizationManager> authorizationManager = builder().simpDestMatchers("destination") .permitAll() .anyMessage() .denyAll() .build(); MessageHeaders headers = new MessageHeaders( - Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination")); + Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination")); Message message = new GenericMessage<>(new Object(), headers); assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue(); } @@ -101,7 +101,7 @@ void checkWhenNullDestinationHeaderMatchesThenUses() { Message message = new GenericMessage<>(new Object()); assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue(); MessageHeaders headers = new MessageHeaders( - Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination")); + Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination")); message = new GenericMessage<>(new Object(), headers); assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isFalse(); } @@ -122,13 +122,13 @@ void checkWhenSimpTypeMatchesThenUses() { // gh-12540 @Test void checkWhenSimpDestinationMatchesThenVariablesExtracted() { - AuthorizationManager> authorizationManager = builder().simpDestMatchers("/destination/*/{id}") + AuthorizationManager> authorizationManager = builder().simpDestMatchers("destination/{id}") .access(variable("id").isEqualTo("3")) .anyMessage() .denyAll() .build(); MessageHeaders headers = new MessageHeaders( - Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/sub/3")); + Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination/3")); Message message = new GenericMessage<>(new Object(), headers); assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue(); } @@ -178,7 +178,13 @@ private Builder variable(String name) { } - private record Builder(String name) { + private static final class Builder { + + private final String name; + + private Builder(String name) { + this.name = name; + } AuthorizationManager> isEqualTo(String value) { return (authentication, object) -> { diff --git a/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java b/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java deleted file mode 100644 index 9192183f2ba..00000000000 --- a/messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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.context.support.GenericApplicationContext; -import org.springframework.web.util.pattern.PathPatternParser; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -class PathPatternMessageMatcherBuilderFactoryBeanTests { - - GenericApplicationContext context; - - @BeforeEach - void setUp() { - this.context = new GenericApplicationContext(); - } - - @Test - void getObjectWhenDefaultsThenBuilder() throws Exception { - factoryBean().getObject(); - } - - @Test - void getObjectWithCustomParserThenUses() throws Exception { - PathPatternParser parser = mock(PathPatternParser.class); - PathPatternMessageMatcher.Builder builder = factoryBean(parser).getObject(); - - builder.matcher("/path/**"); - verify(parser).parse("/path/**"); - } - - PathPatternMessageMatcherBuilderFactoryBean factoryBean() { - PathPatternMessageMatcherBuilderFactoryBean factoryBean = new PathPatternMessageMatcherBuilderFactoryBean(); - return factoryBean; - } - - PathPatternMessageMatcherBuilderFactoryBean factoryBean(PathPatternParser parser) { - PathPatternMessageMatcherBuilderFactoryBean factoryBean = new PathPatternMessageMatcherBuilderFactoryBean( - parser); - return factoryBean; - } - -} 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 index cce440de91d..e776b2dbb53 100644 --- 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 @@ -84,7 +84,7 @@ void matchesFalseWithDotSeparatorAndAdditionalWildcardPathSegment() { @Test void matchesFalseWithDifferentMessageType() { - this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.MESSAGE); + this.matcher = PathPatternMessageMatcher.withDefaults().matcher(SimpMessageType.MESSAGE, "/match"); this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.DISCONNECT); this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/match"); @@ -92,16 +92,16 @@ void matchesFalseWithDifferentMessageType() { } @Test - public void matchesTrueMessageType() { - this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.MESSAGE); + 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 - public void matchesTrueSubscribeType() { - this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.SUBSCRIBE); + 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(); 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 eba7bccaadf..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 @@ -131,7 +131,7 @@ public void typeConstructorParameterIsTransmitted() { } @Test - void illegalStateExceptionThrown_onExtractPathVariables_whenNoMatch() { + public void extractPathVariablesWhenNoMatchThenIllegalState() { this.matcher = new SimpDestinationMessageMatcher("/nomatch"); this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1"); assertThatIllegalStateException()