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 62fc8d8007..0cb3259483 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.Bean; import org.springframework.context.annotation.Scope; import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +import org.springframework.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 15154dc664..372ed1629e 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.AuthorizationManager; 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 final class MessageMatcherDelegatingAuthorizationManager implements Autho } 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 final class MessageMatcherDelegatingAuthorizationManager implements Autho private final List>>> mappings = new ArrayList<>(); + @Deprecated private Supplier pathMatcher = AntPathMatcher::new; public Builder() { @@ -133,11 +140,11 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho * @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 final class MessageMatcherDelegatingAuthorizationManager implements Autho } /** - * 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 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho * 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 final class MessageMatcherDelegatingAuthorizationManager implements Autho * 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 final class MessageMatcherDelegatingAuthorizationManager implements Autho */ 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 final class MessageMatcherDelegatingAuthorizationManager implements Autho } + @Deprecated private final class LazySimpDestinationMessageMatcher implements MessageMatcher { private final Supplier delegate; @@ -421,7 +433,7 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho 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 0000000000..aee72edc7a --- /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 0000000000..81035a7493 --- /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 0000000000..3b7ff09f59 --- /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 d4ae0e15d6..a78a4e8812 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 @@ import org.springframework.util.PathMatcher; * * @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 29cd3ff5e9..857ec20896 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 @@ package org.springframework.security.messaging.access.intercept; 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.authentication.TestingAuthenticationToken; 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 @@ import static org.mockito.Mockito.mock; /** * 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 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests { @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 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests { 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 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests { // 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 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests { } - 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 0000000000..9192183f2b --- /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 0000000000..cce440de91 --- /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 b13bdab5dc..eba7bccaad 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 org.springframework.util.PathMatcher; 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 class SimpDestinationMessageMatcherTests { 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())); + } + }