This is an automated email from the ASF dual-hosted git repository.
lprimak pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git
The following commit(s) were added to refs/heads/main by this push:
new 067169254 [#2929] Use Spring Path matcher for Spring Boot 4.2+ where
Ant matching is removed (#2928)
067169254 is described below
commit 06716925478ef7cf72c523766a7b0a4dff30f979
Author: Lenny Primak <[email protected]>
AuthorDate: Tue Oct 27 16:26:15 2026 -0400
[#2929] Use Spring Path matcher for Spring Boot 4.2+ where Ant matching is
removed (#2928)
* feat(spring,path-matching): introduce SpringPathPatternMatcher for
compatibility with Boot 4.2+
* blank line
---
samples/spring-boot-web/README.md | 13 ++
.../ShiroEnvironmentPostProcessor.java | 14 +-
.../autoconfigure/ShiroWebFilterConfiguration.java | 18 +-
.../web/ShiroWebSpringAutoConfigurationTest.groovy | 11 ++
.../web/autoconfigure/ShiroPathMatchingTest.java | 193 +++++++++++++++++++++
.../shiro/spring/web/ShiroFilterFactoryBean.java | 22 +++
.../shiro/spring/web/SpringPathPatternMatcher.java | 84 +++++++++
.../spring/web/SpringPathPatternMatcherTest.java | 88 ++++++++++
.../shiro/web/filter/PathMatchingFilter.java | 8 +
.../mgt/PathMatchingFilterChainResolver.java | 4 +-
10 files changed, 450 insertions(+), 5 deletions(-)
diff --git a/samples/spring-boot-web/README.md
b/samples/spring-boot-web/README.md
index 34edf4d09..d57ae8427 100644
--- a/samples/spring-boot-web/README.md
+++ b/samples/spring-boot-web/README.md
@@ -12,3 +12,16 @@ mvn spring-boot:run
```
Browse to `http://localhost:8080/`
+
+Path Matching
+-------------
+
+The starter's environment post-processor requests Ant path matching from
Spring MVC when Boot still supports it.
+If MVC instead uses Spring's native `PathPatternParser` (for example, when the
Ant setting is no longer supported), the starter
+uses Spring's parsed patterns for both Shiro filter-chain selection and
path-aware filters.
+Shiro chain definitions then use Spring path-pattern syntax, and invalid
patterns fail during startup.
+Matching remains case-insensitive by default, independently of MVC's parser
setting, to preserve protection for
+static resources on case-insensitive filesystems. Set
`shiro.caseInsensitive=false` to explicitly opt into
+case-sensitive matching. Shiro also retains its trailing-slash retry: `/admin`
and `/admin/` select the same
+restriction, even if MVC distinguishes those paths. Filter-chain definitions
retain Shiro's first-match ordering
+and deny-by-default fallback.
diff --git
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
index e253d4b71..84bffb60d 100644
---
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
+++
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
@@ -18,21 +18,31 @@
*/
package org.apache.shiro.spring.config.web.autoconfigure;
+import org.jspecify.annotations.NonNull;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
+import
org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.MatchingStrategy;
import org.springframework.core.PriorityOrdered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import java.util.Collections;
+import java.util.Arrays;
class ShiroEnvironmentPostProcessor implements EnvironmentPostProcessor,
PriorityOrdered {
+ static final String ANT_PATH_MATCHER = "ant_path_matcher";
+
@Override
- public void postProcessEnvironment(ConfigurableEnvironment environment,
SpringApplication application) {
+ public void postProcessEnvironment(@NonNull ConfigurableEnvironment
environment,
+ @NonNull SpringApplication application)
{
+ if (Arrays.stream(MatchingStrategy.values())
+ .noneMatch(strategy ->
strategy.name().equalsIgnoreCase(ANT_PATH_MATCHER))) {
+ return;
+ }
// force the use of the ant path matcher
environment.getPropertySources()
.addFirst(new MapPropertySource("shiro",
-
Collections.singletonMap("spring.mvc.pathmatch.matching-strategy",
"ant_path_matcher")));
+
Collections.singletonMap("spring.mvc.pathmatch.matching-strategy",
ANT_PATH_MATCHER)));
}
@Override
diff --git
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
index a78cbf09c..c2f73b826 100644
---
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
+++
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
@@ -20,18 +20,23 @@ package org.apache.shiro.spring.config.web.autoconfigure;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.ShiroFilterFactoryBeanPostProcessor;
+import org.apache.shiro.spring.web.SpringPathPatternMatcher;
import org.apache.shiro.spring.web.config.AbstractShiroWebFilterConfiguration;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import
org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import jakarta.servlet.DispatcherType;
import org.springframework.context.annotation.Lazy;
import java.util.List;
+import java.util.Objects;
/**
* @since 1.4.0
@@ -50,11 +55,22 @@ public class ShiroWebFilterConfiguration extends
AbstractShiroWebFilterConfigura
*/
public static final String FILTER_NAME = "shiroFilter";
+ @Autowired
+ protected ObjectProvider<RequestMappingHandlerMapping> handlerMappings;
+
@Bean
@ConditionalOnMissingBean
@Override
protected ShiroFilterFactoryBean shiroFilterFactoryBean() {
- return super.shiroFilterFactoryBean();
+ ShiroFilterFactoryBean factoryBean = super.shiroFilterFactoryBean();
+
handlerMappings.orderedStream().map(RequestMappingHandlerMapping::getPatternParser).filter(Objects::nonNull)
+ .findFirst().ifPresent(parser -> {
+ SpringPathPatternMatcher matcher = new
SpringPathPatternMatcher(parser);
+ matcher.setCaseInsensitive(caseInsensitive);
+
factoryBean.getFilterChainDefinitionMap().keySet().forEach(matcher::validatePattern);
+ factoryBean.setPathMatcher(matcher);
+ });
+ return factoryBean;
}
@Bean
diff --git
a/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
b/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
index 1aa2e2b72..842323d0b 100644
---
a/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
+++
b/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
@@ -22,6 +22,7 @@ import org.apache.shiro.SecurityUtils
import
org.apache.shiro.spring.boot.autoconfigure.web.application.ShiroWebAutoConfigurationTestApplication
import
org.apache.shiro.spring.boot.autoconfigure.web.application.ShiroWebAutoConfigurationTestApplication.EventBusAwareObject
import
org.apache.shiro.spring.boot.autoconfigure.web.application.ShiroWebAutoConfigurationTestApplication.SubscribedListener
+import org.apache.shiro.spring.web.SpringPathPatternMatcher
import org.apache.shiro.event.EventBus
import org.apache.shiro.mgt.DefaultSecurityManager
@@ -34,6 +35,7 @@ import org.junit.jupiter.api.Test
import org.springframework.aop.framework.Advised
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
+import
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
import static org.hamcrest.Matchers.contains
import static org.hamcrest.Matchers.equalTo
@@ -62,6 +64,9 @@ class ShiroWebSpringAutoConfigurationTest {
@Autowired
private AbstractShiroFilter shiroFilter
+ @Autowired
+ private RequestMappingHandlerMapping requestMappingHandlerMapping
+
@Test
void testMinimalConfiguration() {
@@ -72,6 +77,12 @@ class ShiroWebSpringAutoConfigurationTest {
assertNotNull eventBusAwareObject
assertNotNull eventBus
assertNotNull shiroFilter
+ if (shiroFilter.filterChainResolver.pathMatcher instanceof
SpringPathPatternMatcher) {
+ assertNotNull requestMappingHandlerMapping.patternParser
+ } else {
+ assertNull requestMappingHandlerMapping.patternParser
+ assertThat requestMappingHandlerMapping.pathMatcher,
instanceOf(org.springframework.util.AntPathMatcher)
+ }
assertTrue(eventBus.registry.containsKey(subscribedListener))
assertSame(((Advised)eventBusAwareObject.getEventBus()).getTargetSource().getTarget(),
eventBus)
assertSame(((Advised)
SecurityUtils.unwrapSecurityManager(securityManager, DefaultSecurityManager)
diff --git
a/support/spring-boot/spring-boot-starter/src/test/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroPathMatchingTest.java
b/support/spring-boot/spring-boot-starter/src/test/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroPathMatchingTest.java
new file mode 100644
index 000000000..ec6e63a5b
--- /dev/null
+++
b/support/spring-boot/spring-boot-starter/src/test/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroPathMatchingTest.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.shiro.spring.config.web.autoconfigure;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import org.apache.shiro.spring.web.SpringPathPatternMatcher;
+import org.apache.shiro.util.AntPathMatcher;
+import org.apache.shiro.web.filter.PathMatchingFilter;
+import org.apache.shiro.web.filter.mgt.DefaultFilterChainManager;
+import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
+import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
+import org.apache.shiro.web.servlet.AbstractShiroFilter;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.util.pattern.PatternParseException;
+import
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import java.util.Map;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import static
org.apache.shiro.spring.config.web.autoconfigure.ShiroEnvironmentPostProcessor.ANT_PATH_MATCHER;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ShiroPathMatchingTest {
+
+ @Test
+ void preservesLegacyPostProcessorAndMatcher() throws Exception {
+ MockEnvironment environment = new MockEnvironment();
+ new
ShiroEnvironmentPostProcessor().postProcessEnvironment(environment, new
SpringApplication());
+ boolean antSupported =
Arrays.stream(WebMvcProperties.MatchingStrategy.values())
+ .anyMatch(strategy ->
strategy.name().equalsIgnoreCase(ANT_PATH_MATCHER));
+
assertThat(environment.getProperty("spring.mvc.pathmatch.matching-strategy"))
+ .isEqualTo(antSupported ? ANT_PATH_MATCHER : null);
+ TestConfiguration configuration = new TestConfiguration("/users/*",
null);
+
assertThat(resolver(configuration).getPathMatcher()).isInstanceOf(AntPathMatcher.class);
+ }
+
+ @Test
+ void usesSpringPatternsWhenMvcDidNotApplyAntSetting() throws Exception {
+ TestConfiguration configuration = new TestConfiguration("/users/{id}",
new PathPatternParser());
+ PathMatchingFilterChainResolver resolver = resolver(configuration);
+
assertThat(resolver.getPathMatcher()).isInstanceOf(SpringPathPatternMatcher.class);
+ assertThat(resolver.isCaseInsensitive()).isTrue();
+ MockHttpServletRequest request = request("/users/42");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ resolver.getChain(request, response, (req, res) -> {
}).doFilter(request, response);
+ assertThat(configuration.probe.matches).isEqualTo(1);
+
+ DefaultFilterChainManager manager = (DefaultFilterChainManager)
resolver.getFilterChainManager();
+ manager.getFilterChains().remove("/**");
+ assertThat(resolver.getChain(request("/users/42/"), response, (req,
res) -> { })).isNotNull();
+ assertThat(resolver.getChain(request("/Users/42"), response, (req,
res) -> { })).isNotNull();
+ configuration.probe.doFilter(request("/users/42/"), response, (req,
res) -> { });
+ assertThat(configuration.probe.matches).isEqualTo(2);
+ }
+
+ @Test
+ void preservesTrailingSlashProtectionBeforeAnonymousFallback() throws
Exception {
+ for (PathPatternParser parser : new PathPatternParser[]{null, new
PathPatternParser()}) {
+ String userPattern = parser == null ? "/users/*" : "/users/{id}";
+ for (String pattern : new String[]{"/admin", "/admin/",
userPattern, userPattern + "/", "/files/**"}) {
+ Map<String, String> chains = new LinkedHashMap<>();
+ chains.put(pattern, "probe");
+ chains.put("/**", "anon");
+ TestConfiguration configuration = new
TestConfiguration(chains, parser);
+ PathMatchingFilterChainResolver resolver =
resolver(configuration);
+ String path = pattern.replace("{id}", "42").replace("**",
"private/report.pdf").replace("*", "42");
+ String alternate = path.endsWith("/") ? path.substring(0,
path.length() - 1) : path + "/";
+ for (String requestPath : new String[]{path, alternate}) {
+ MockHttpServletRequest request = request(requestPath);
+ boolean[] resourceServed = {false};
+ resolver.getChain(request, new MockHttpServletResponse(),
(req, res) -> resourceServed[0] = true)
+ .doFilter(request, new MockHttpServletResponse());
+ assertThat(resourceServed[0]).as("%s against %s", pattern,
requestPath).isFalse();
+ }
+ assertThat(configuration.probe.matches).as("filter execution
for %s", pattern).isEqualTo(2);
+ }
+ }
+ }
+
+ @Test
+ void rejectsInvalidSpringPatternsDuringConfiguration() {
+ assertThatThrownBy(() -> new TestConfiguration("/users/{id", new
PathPatternParser()).shiroFilterFactoryBean())
+ .isInstanceOf(PatternParseException.class);
+ }
+
+ @Test
+ void honorsExplicitCaseSensitivity() throws Exception {
+ PathPatternParser parser = new PathPatternParser();
+ parser.setCaseSensitive(false);
+ TestConfiguration configuration = new
TestConfiguration(Map.of("/users/{id}", "probe"), parser, false);
+ PathMatchingFilterChainResolver resolver = resolver(configuration);
+ assertThat(resolver.isCaseInsensitive()).isFalse();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockHttpServletRequest request = request("/users/42");
+ resolver.getChain(request, response, (req, res) -> {
}).doFilter(request, response);
+ assertThat(configuration.probe.matches).isEqualTo(1);
+ DefaultFilterChainManager manager = (DefaultFilterChainManager)
resolver.getFilterChainManager();
+ manager.getFilterChains().remove("/**");
+ assertThat(resolver.getChain(request("/Users/42"), response, (req,
res) -> { })).isNull();
+ configuration.probe.doFilter(request("/Users/42"), response, (req,
res) -> { });
+ assertThat(configuration.probe.matches).isEqualTo(1);
+ }
+
+ @Test
+ void protectsMixedCaseStaticPathsBeforeAnonymousFallback() throws
Exception {
+ Map<String, String> chains = new LinkedHashMap<>();
+ chains.put("/files/private/**", "probe");
+ chains.put("/files/**", "anon");
+ TestConfiguration configuration = new TestConfiguration(chains, new
PathPatternParser());
+ PathMatchingFilterChainResolver resolver = resolver(configuration);
+ MockHttpServletRequest request = request("/files/PRIVATE/report.pdf");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ boolean[] resourceServed = {false};
+ resolver.getChain(request, response, (req, res) -> resourceServed[0] =
true).doFilter(request, response);
+ assertThat(configuration.probe.matches).isEqualTo(1);
+ assertThat(resourceServed[0]).isFalse();
+ }
+
+ private static PathMatchingFilterChainResolver resolver(TestConfiguration
configuration) throws Exception {
+ AbstractShiroFilter filter = (AbstractShiroFilter)
configuration.shiroFilterFactoryBean().getObject();
+ return (PathMatchingFilterChainResolver)
filter.getFilterChainResolver();
+ }
+
+ private static MockHttpServletRequest request(String path) {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setServletPath(path);
+ return request;
+ }
+
+ private static class TestConfiguration extends ShiroWebFilterConfiguration
{
+ private final ProbeFilter probe = new ProbeFilter();
+
+ TestConfiguration(String pattern, PathPatternParser parser) {
+ this(Map.of(pattern, "probe"), parser);
+ }
+
+ TestConfiguration(Map<String, String> chains, PathPatternParser
parser) {
+ this(chains, parser, true);
+ }
+
+ TestConfiguration(Map<String, String> chains, PathPatternParser
parser, boolean ignoreCase) {
+ securityManager = new DefaultWebSecurityManager();
+ shiroFilterChainDefinition = () -> chains;
+ caseInsensitive = ignoreCase;
+ RequestMappingHandlerMapping mapping = new
RequestMappingHandlerMapping();
+ mapping.setPatternParser(parser);
+ DefaultListableBeanFactory factory = new
DefaultListableBeanFactory();
+ factory.registerSingleton("requestMappingHandlerMapping", mapping);
+ handlerMappings =
factory.getBeanProvider(RequestMappingHandlerMapping.class);
+ }
+
+ @Override
+ protected Map<String, Filter> filterMap() {
+ return Map.of("probe", probe);
+ }
+ }
+
+ private static final class ProbeFilter extends PathMatchingFilter {
+ private int matches;
+
+ @Override
+ protected boolean onPreHandle(ServletRequest request, ServletResponse
response, Object mappedValue) {
+ matches++;
+ return false;
+ }
+ }
+}
diff --git
a/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
b/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
index 7ed8a9d80..938b28824 100644
---
a/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
+++
b/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
@@ -21,12 +21,14 @@ package org.apache.shiro.spring.web;
import org.apache.shiro.config.Ini;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.util.CollectionUtils;
+import org.apache.shiro.util.PatternMatcher;
import org.apache.shiro.lang.util.Nameable;
import org.apache.shiro.lang.util.StringUtils;
import org.apache.shiro.web.config.IniFilterChainResolverFactory;
import org.apache.shiro.web.config.ShiroFilterConfiguration;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.filter.InvalidRequestFilter;
+import org.apache.shiro.web.filter.PathMatchingFilter;
import org.apache.shiro.web.filter.authc.AuthenticationFilter;
import org.apache.shiro.web.filter.authz.AuthorizationFilter;
import org.apache.shiro.web.filter.mgt.DefaultFilter;
@@ -129,6 +131,7 @@ public class ShiroFilterFactoryBean implements
FactoryBean<Filter> {
private String unauthorizedUrl;
private boolean allowAccessByDefault;
private boolean caseInsensitive = true;
+ private PatternMatcher pathMatcher;
private AbstractShiroFilter instance;
@@ -292,6 +295,14 @@ public class ShiroFilterFactoryBean implements
FactoryBean<Filter> {
this.caseInsensitive = caseInsensitive;
}
+ /**
+ * Sets the matcher for filter-chain resolution and path-aware filters.
+ * @param pathMatcher the path matcher, or {@code null} for the default
Ant matcher
+ */
+ public void setPathMatcher(PatternMatcher pathMatcher) {
+ this.pathMatcher = pathMatcher;
+ }
+
/**
* Returns the filterName-to-Filter map of filters available for reference
when defining filter chain definitions.
* All filter chain definitions will reference filters by the names in
this map (i.e. the keys).
@@ -437,6 +448,9 @@ public class ShiroFilterFactoryBean implements
FactoryBean<Filter> {
protected FilterChainManager createFilterChainManager() {
DefaultFilterChainManager manager = new DefaultFilterChainManager();
+ if (pathMatcher != null) {
+ pathMatcher.setCaseInsensitive(caseInsensitive);
+ }
manager.setCaseInsensitive(caseInsensitive);
Map<String, Filter> defaultFilters = manager.getFilters();
//apply global settings if necessary:
@@ -523,6 +537,9 @@ public class ShiroFilterFactoryBean implements
FactoryBean<Filter> {
// FilterChainResolver implementation. The AbstractShiroFilter
implementations
// do not know about FilterChainManagers - only resolvers:
PathMatchingFilterChainResolver chainResolver = new
PathMatchingFilterChainResolver().caseInsensitive(caseInsensitive);
+ if (pathMatcher != null) {
+ chainResolver.setPathMatcher(pathMatcher);
+ }
chainResolver.setFilterChainManager(manager);
//Now create a concrete ShiroFilter instance and apply the acquired
SecurityManager and built
@@ -566,6 +583,11 @@ public class ShiroFilterFactoryBean implements
FactoryBean<Filter> {
}
void applyGlobalPropertiesIfNecessary(Filter filter) {
+ if (filter instanceof PathMatchingFilter pathMatchingFilter) {
+ if (pathMatcher != null) {
+ pathMatchingFilter.setPathMatcher(pathMatcher);
+ }
+ }
applyLoginUrlIfNecessary(filter);
applySuccessUrlIfNecessary(filter);
applyUnauthorizedUrlIfNecessary(filter);
diff --git
a/support/spring/src/main/java/org/apache/shiro/spring/web/SpringPathPatternMatcher.java
b/support/spring/src/main/java/org/apache/shiro/spring/web/SpringPathPatternMatcher.java
new file mode 100644
index 000000000..a7305a668
--- /dev/null
+++
b/support/spring/src/main/java/org/apache/shiro/spring/web/SpringPathPatternMatcher.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.shiro.spring.web;
+
+import org.apache.shiro.util.PatternMatcher;
+import org.springframework.http.server.PathContainer;
+import org.springframework.http.server.PathContainer.Options;
+import org.springframework.web.util.pattern.PathPattern;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Matches Shiro's decoded application paths using Spring's parsed path
patterns.
+ * Matching is case-insensitive by default, consistent with Shiro's Ant
matcher.
+ */
+public class SpringPathPatternMatcher implements PatternMatcher {
+ private final PathPatternParser parser;
+ private final ConcurrentMap<String, PathPattern> patterns = new
ConcurrentHashMap<>();
+
+ public SpringPathPatternMatcher() {
+ this(new PathPatternParser());
+ }
+
+ public SpringPathPatternMatcher(PathPatternParser mvcParser) {
+ parser = new PathPatternParser();
+ parser.setCaseSensitive(false);
+ parser.setPathOptions(mvcParser.getPathOptions());
+ }
+
+ /**
+ * Parses and caches a configured pattern, rejecting invalid patterns at
startup.
+ * @param pattern the configured pattern
+ * @return the parsed pattern
+ */
+ public PathPattern validatePattern(String pattern) {
+ return patterns.computeIfAbsent(pattern, parser::parse);
+ }
+
+ @Override
+ public boolean matches(String pattern, String source) {
+ // Servlet paths have already been decoded; do not decode percent
sequences a second time.
+ return validatePattern(pattern).matches(PathContainer.parsePath(source,
+ Options.create('/', false)));
+ }
+
+ @Override
+ public boolean isCaseInsensitive() {
+ return !parser.isCaseSensitive();
+ }
+
+ /**
+ * Configures case sensitivity before any patterns have been parsed.
+ * Reapplying the current setting is allowed.
+ * @param caseInsensitive whether matching should ignore case
+ * @throws IllegalStateException if changing the setting after a pattern
has been parsed
+ */
+ @Override
+ public void setCaseInsensitive(boolean caseInsensitive) {
+ if (isCaseInsensitive() != caseInsensitive) {
+ if (!patterns.isEmpty()) {
+ throw new IllegalStateException("Cannot change case
sensitivity after path patterns have been parsed.");
+ }
+ parser.setCaseSensitive(!caseInsensitive);
+ }
+ }
+}
diff --git
a/support/spring/src/test/java/org/apache/shiro/spring/web/SpringPathPatternMatcherTest.java
b/support/spring/src/test/java/org/apache/shiro/spring/web/SpringPathPatternMatcherTest.java
new file mode 100644
index 000000000..4fb881948
--- /dev/null
+++
b/support/spring/src/test/java/org/apache/shiro/spring/web/SpringPathPatternMatcherTest.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.shiro.spring.web;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+class SpringPathPatternMatcherTest {
+
+ @Test
+ void matchesSpringPatterns() {
+ SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+ PathPatternParser parser = new PathPatternParser();
+ parser.setCaseSensitive(false);
+ for (String pattern : new String[]{"/users/{id}",
"/users/{id:[0-9]+}", "/users/*", "/users/**", "/users/{*rest}"}) {
+ for (String path : new String[]{"/users", "/users/42",
"/users/42/", "/users/42/profile", "/Users/42"}) {
+ assertThat(matcher.matches(pattern, path))
+ .as("%s against %s", pattern, path)
+ .isEqualTo(parser.parse(pattern).matches(
+
org.springframework.http.server.PathContainer.parsePath(path)));
+ }
+ }
+ }
+
+ @Test
+ void defaultsToCaseInsensitiveRegardlessOfMvcSetting() {
+ assertThat(new
SpringPathPatternMatcher().isCaseInsensitive()).isTrue();
+ PathPatternParser parser = new PathPatternParser();
+ assertThat(parser.isCaseSensitive()).isTrue();
+ SpringPathPatternMatcher matcher = new
SpringPathPatternMatcher(parser);
+ assertThat(matcher.matches("/files/private/**",
"/files/PRIVATE/report.pdf")).isTrue();
+ assertThat(parser.isCaseSensitive()).isTrue();
+ }
+
+ @Test
+ void doesNotDecodeServletPathsTwice() {
+ SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+ assertThat(matcher.matches("/users/admin",
"/users/%61dmin")).isFalse();
+ assertThat(matcher.matches("/users/{id}", "/users/Jane Doe")).isTrue();
+ }
+
+ @Test
+ void configuresCaseSensitivityBeforeParsing() {
+ SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+ matcher.setCaseInsensitive(false);
+ assertThat(matcher.matches("/users/{id}", "/Users/42")).isFalse();
+ matcher.setCaseInsensitive(false);
+ assertThat(matcher.matches("/users/{id}", "/Users/42")).isFalse();
+ }
+
+ @Test
+ void rejectsChangingCaseSensitivityAfterMatching() {
+ SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+ assertThat(matcher.matches("/users/{id}", "/Users/42")).isTrue();
+ assertThatIllegalStateException().isThrownBy(() ->
matcher.setCaseInsensitive(false));
+ matcher.setCaseInsensitive(true);
+ assertThat(matcher.matches("/users/{id}", "/Users/42")).isTrue();
+ }
+
+ @Test
+ void rejectsChangingCaseSensitivityAfterValidation() {
+ SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+ matcher.setCaseInsensitive(true);
+ matcher.validatePattern("/users/{id}");
+ assertThatIllegalStateException().isThrownBy(() ->
matcher.setCaseInsensitive(false));
+ matcher.setCaseInsensitive(true);
+ assertThat(matcher.matches("/users/{id}", "/Users/42")).isTrue();
+ }
+}
diff --git
a/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java
b/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java
index c332a9c2d..4f035df73 100644
--- a/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java
+++ b/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java
@@ -63,6 +63,14 @@ public abstract class PathMatchingFilter extends
AdviceFilter implements PathCon
*/
protected Map<String, Object> appliedPaths = new LinkedHashMap<String,
Object>();
+ /**
+ * Sets the matcher used for this filter's configured paths.
+ * @param pathMatcher the path matcher
+ */
+ public void setPathMatcher(PatternMatcher pathMatcher) {
+ this.pathMatcher = pathMatcher;
+ }
+
/**
* Splits any comma-delimited values that might be found in the
<code>config</code> argument and sets the resulting
* <code>String[]</code> array on the <code>appliedPaths</code> internal
Map.
diff --git
a/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
b/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
index f2d24d4d5..91968102d 100644
---
a/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
+++
b/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
@@ -138,9 +138,9 @@ public class PathMatchingFilterChainResolver implements
FilterChainResolver {
// but the pathPattern match "/resource/menus" can not match
"resource/menus/"
// user can use requestURI + "/" to simply bypassed chain
filter, to bypassed shiro protect
- pathPattern = removeTrailingSlash(pathPattern);
+ String pathPatternNoTrailingSlash =
removeTrailingSlash(pathPattern);
- if (pathMatches(pathPattern, requestURINoTrailingSlash)) {
+ if (pathMatches(pathPatternNoTrailingSlash,
requestURINoTrailingSlash)) {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("Matched path pattern [{}] for requestURI
[{}]. "
+ "Utilizing corresponding filter chain...",