From 763a0eaa921cb00bae0ea6cb9574c12262fe33b3 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:15:38 -0700 Subject: [PATCH 1/4] Add PathPatternRequestMatcher Closes gh-16429 --- .../matcher/PathPatternRequestMatcher.java | 207 ++++++++++++++++++ .../util/matcher/RequestMatcherBuilder.java | 48 ++++ .../PathPatternRequestMatcherTests.java | 93 ++++++++ 3 files changed, 348 insertions(+) create mode 100644 web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java create mode 100644 web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java create mode 100644 web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java diff --git a/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java new file mode 100644 index 00000000000..7d7ba3e34aa --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java @@ -0,0 +1,207 @@ +/* + * 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.web.servlet.util.matcher; + +import java.util.Objects; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; +import org.springframework.util.Assert; +import org.springframework.web.util.ServletRequestPathUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * A {@link RequestMatcher} that uses {@link PathPattern}s to match against each + * {@link HttpServletRequest}. Specifically, this means that the class anticipates that + * the provided pattern does not include the servlet path in order to align with Spring + * MVC. + * + *

+ * Note that the {@link org.springframework.web.servlet.HandlerMapping} that contains the + * related URI patterns must be using the same + * {@link org.springframework.web.util.pattern.PathPatternParser} configured in this + * class. + *

+ * + * @author Josh Cummings + * @since 6.5 + */ +public final class PathPatternRequestMatcher implements RequestMatcher { + + private static final String PATH_ATTRIBUTE = PathPatternRequestMatcher.class + ".PATH"; + + static final String ANY_SERVLET = new String(); + + private final PathPattern pattern; + + private String servletPath; + + private HttpMethod method; + + PathPatternRequestMatcher(PathPattern pattern) { + this.pattern = pattern; + } + + /** + * Create a {@link Builder} for creating {@link PathPattern}-based request matchers. + * That is, matchers that anticipate patterns do not specify the servlet path. + * @return the {@link Builder} + */ + public static Builder builder() { + return new Builder(PathPatternParser.defaultInstance); + } + + /** + * Create a {@link Builder} for creating {@link PathPattern}-based request matchers. + * That is, matchers that anticipate patterns do not specify the servlet path. + * @param parser the {@link PathPatternParser}; only needed when different from + * {@link PathPatternParser#defaultInstance} + * @return the {@link Builder} + */ + public static Builder withPathPatternParser(PathPatternParser parser) { + return new Builder(parser); + } + + @Override + public boolean matches(HttpServletRequest request) { + return matcher(request).isMatch(); + } + + @Override + public MatchResult matcher(HttpServletRequest request) { + if (this.method != null && !this.method.name().equals(request.getMethod())) { + return MatchResult.notMatch(); + } + if (this.servletPath != null && !this.servletPath.equals(request.getServletPath()) + && !ANY_SERVLET.equals(this.servletPath)) { + return MatchResult.notMatch(); + } + PathContainer path = getPathContainer(request); + PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(path); + return (info != null) ? MatchResult.match(info.getUriVariables()) : MatchResult.notMatch(); + } + + PathContainer getPathContainer(HttpServletRequest request) { + if (this.servletPath != null) { + return ServletRequestPathUtils.parseAndCache(request).pathWithinApplication(); + } + else { + return parseAndCache(request); + } + } + + PathContainer parseAndCache(HttpServletRequest request) { + PathContainer path = (PathContainer) request.getAttribute(PATH_ATTRIBUTE); + if (path != null) { + return path; + } + path = RequestPath.parse(request.getRequestURI(), request.getContextPath()).pathWithinApplication(); + request.setAttribute(PATH_ATTRIBUTE, path); + return path; + } + + void setServletPath(String servletPath) { + this.servletPath = servletPath; + } + + void setMethod(HttpMethod method) { + this.method = method; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof PathPatternRequestMatcher that)) { + return false; + } + return Objects.equals(this.pattern, that.pattern) && Objects.equals(this.servletPath, that.servletPath) + && Objects.equals(this.method, that.method); + } + + @Override + public int hashCode() { + return Objects.hash(this.pattern, this.servletPath, this.method); + } + + @Override + public String toString() { + return "PathPatternRequestMatcher [pattern=" + this.pattern + ", servletPath=" + this.servletPath + ", method=" + + this.method + ']'; + } + + /** + * A builder for {@link MvcRequestMatcher} + * + * @author Marcus Da Coregio + * @since 6.5 + */ + public static final class Builder implements RequestMatcherBuilder { + + private final PathPatternParser parser; + + private HttpMethod method; + + private String servletPath; + + /** + * Construct a new instance of this builder + */ + public Builder(PathPatternParser parser) { + Assert.notNull(parser, "pathPatternParser cannot be null"); + this.parser = parser; + } + + public Builder method(HttpMethod method) { + this.method = method; + return this; + } + + /** + * Sets the servlet path to be used by the {@link MvcRequestMatcher} generated by + * this builder + * @param servletPath the servlet path to use + * @return the {@link MvcRequestMatcher.Builder} for further configuration + */ + public Builder servletPath(String servletPath) { + this.servletPath = servletPath; + return this; + } + + /** + * Creates an {@link MvcRequestMatcher} that uses the provided pattern and HTTP + * method to match + * @param method the {@link HttpMethod}, can be null + * @param pattern the patterns used to match + * @return the generated {@link MvcRequestMatcher} + */ + public PathPatternRequestMatcher pattern(HttpMethod method, String pattern) { + String parsed = this.parser.initFullPathPattern(pattern); + PathPattern pathPattern = this.parser.parse(parsed); + PathPatternRequestMatcher requestMatcher = new PathPatternRequestMatcher(pathPattern); + requestMatcher.setServletPath(this.servletPath); + requestMatcher.setMethod(method); + return requestMatcher; + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java b/web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java new file mode 100644 index 00000000000..31ff6957a53 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java @@ -0,0 +1,48 @@ +/* + * 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.web.util.matcher; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.http.HttpMethod; + +public interface RequestMatcherBuilder { + + default RequestMatcher[] pattern(HttpMethod method, String... patterns) { + List requestMatchers = new ArrayList<>(); + for (String pattern : patterns) { + requestMatchers.add(pattern(method, pattern)); + } + return requestMatchers.toArray(RequestMatcher[]::new); + } + + default RequestMatcher[] pattern(String... patterns) { + return pattern(null, patterns); + } + + default RequestMatcher pattern(String pattern) { + return pattern(null, pattern); + } + + default RequestMatcher anyRequest() { + return pattern(null, "/**"); + } + + RequestMatcher pattern(HttpMethod method, String pattern); + +} diff --git a/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java new file mode 100644 index 00000000000..998a738f332 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java @@ -0,0 +1,93 @@ +/* + * 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.web.servlet.util.matcher; + +import jakarta.servlet.http.MappingMatch; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletMapping; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PathPatternRequestMatcher} + */ +public class PathPatternRequestMatcherTests { + + @Test + void matcherWhenPatternMatchesRequestThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern("/uri"); + assertThat(matcher.matches(request("GET", "/uri"))).isTrue(); + } + + @Test + void matcherWhenPatternContainsPlaceholdersThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern("/uri/{username}"); + assertThat(matcher.matcher(request("GET", "/uri/bob")).getVariables()).containsEntry("username", "bob"); + } + + @Test + void matcherWhenSameServletPathThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().servletPath("/mvc").pattern("/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isTrue(); + } + + @Test + void matcherWhenSameMethodThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/uri"))).isTrue(); + } + + @Test + void matcherWhenDifferentPathThenNotMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder() + .servletPath("/mvc") + .pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/uri", ""))).isFalse(); + } + + @Test + void matcherWhenDifferentMethodThenNotMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder() + .servletPath("/mvc") + .pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("POST", "/mvc/uri", "/mvc"))).isFalse(); + } + + @Test + void matcherWhenNoServletPathThenMatchAbsolute() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isFalse(); + assertThat(matcher.matches(request("GET", "/uri", ""))).isTrue(); + } + + MockHttpServletRequest request(String method, String uri) { + return new MockHttpServletRequest(method, uri); + } + + MockHttpServletRequest request(String method, String uri, String servletPath) { + MockHttpServletRequest request = new MockHttpServletRequest(method, uri); + request.setServletPath(servletPath); + request + .setHttpServletMapping(new MockHttpServletMapping(uri, servletPath + "/*", "servlet", MappingMatch.PATH)); + return request; + } + +} From ae29d07ee26065b71a7ef1a2b1a6e10fc77010e8 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:57:56 -0700 Subject: [PATCH 2/4] Add Servlet Path support to Java DSL Closes gh-16430 --- .../web/AbstractRequestMatcherRegistry.java | 37 ++- .../AbstractRequestMatcherRegistryTests.java | 9 + .../AuthorizeHttpRequestsConfigurerTests.java | 40 +++ docs/modules/ROOT/pages/migration-7/web.adoc | 20 ++ .../authorize-http-requests.adoc | 109 ++++++-- .../matcher/ServletRegistrationsSupport.java | 77 ++++++ .../ServletRequestMatcherBuilders.java | 249 ++++++++++++++++++ .../ServletRequestMatcherBuildersTests.java | 171 ++++++++++++ 8 files changed, 679 insertions(+), 33 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRegistrationsSupport.java create mode 100644 web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuilders.java create mode 100644 web/src/test/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuildersTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java index 4f849f86fb1..2181da01aea 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java @@ -49,6 +49,7 @@ import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RegexRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.web.context.WebApplicationContext; @@ -74,6 +75,8 @@ public abstract class AbstractRequestMatcherRegistry { private static final RequestMatcher ANY_REQUEST = AnyRequestMatcher.INSTANCE; + private final RequestMatcherBuilder requestMatcherBuilder = new DefaultRequestMatcherBuilder(); + private ApplicationContext context; private boolean anyRequestConfigured = false; @@ -217,13 +220,9 @@ public C requestMatchers(HttpMethod method, String... patterns) { if (servletContext == null) { return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns)); } - List matchers = new ArrayList<>(); - for (String pattern : patterns) { - AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null); - MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0); - matchers.add(new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant)); - } - return requestMatchers(matchers.toArray(new RequestMatcher[0])); + RequestMatcherBuilder builder = context.getBeanProvider(RequestMatcherBuilder.class) + .getIfUnique(() -> this.requestMatcherBuilder); + return requestMatchers(builder.pattern(method, patterns)); } private boolean anyPathsDontStartWithLeadingSlash(String... patterns) { @@ -264,11 +263,14 @@ private RequestMatcher resolve(AntPathRequestMatcher ant, MvcRequestMatcher mvc, } private static String computeErrorMessage(Collection registrations) { - String template = "This method cannot decide whether these patterns are Spring MVC patterns or not. " - + "If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); " - + "otherwise, please use requestMatchers(AntPathRequestMatcher).\n\n" - + "This is because there is more than one mappable servlet in your servlet context: %s.\n\n" - + "For each MvcRequestMatcher, call MvcRequestMatcher#setServletPath to indicate the servlet path."; + String template = """ + This method cannot decide whether these patterns are Spring MVC patterns or not. \ + This is because there is more than one mappable servlet in your servlet context: %s. + + To address this, please create one ServletRequestMatcherBuilder#servletPath for each servlet that has \ + authorized endpoints and use them to construct request matchers manually. \ + If all your URIs are unambiguous, then you can simply publish one ServletRequestMatcherBuilders#servletPath as \ + a @Bean and Spring Security will use it for all URIs"""; Map> mappings = new LinkedHashMap<>(); for (ServletRegistration registration : registrations) { mappings.put(registration.getClassName(), registration.getMappings()); @@ -402,6 +404,17 @@ static List regexMatchers(String... regexPatterns) { } + class DefaultRequestMatcherBuilder implements RequestMatcherBuilder { + + @Override + public RequestMatcher pattern(HttpMethod method, String pattern) { + AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null); + MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0); + return new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant); + } + + } + static class DeferredRequestMatcher implements RequestMatcher { final Function requestMatcherFactory; diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java index 70f383c203a..4a7ae59d07e 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.BeansException; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; @@ -42,6 +43,7 @@ import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher; import org.springframework.security.web.util.matcher.RegexRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; @@ -87,6 +89,13 @@ public void setUp() { given(given).willReturn(postProcessors); given(postProcessors.getObject()).willReturn(NO_OP_OBJECT_POST_PROCESSOR); given(this.context.getServletContext()).willReturn(MockServletContext.mvc()); + ObjectProvider requestMatcherFactories = new ObjectProvider<>() { + @Override + public RequestMatcherBuilder getObject() throws BeansException { + return AbstractRequestMatcherRegistryTests.this.matcherRegistry.new DefaultRequestMatcherBuilder(); + } + }; + given(this.context.getBeanProvider(RequestMatcherBuilder.class)).willReturn(requestMatcherFactories); this.matcherRegistry.setApplicationContext(this.context); mockMvcIntrospector(true); } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java index 41850d67561..1d36d3f6383 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java @@ -64,6 +64,8 @@ import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.RequestPostProcessor; @@ -72,6 +74,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @@ -667,6 +670,19 @@ public void getWhenExcludeAuthorizationObservationsThenUnobserved() throws Excep verifyNoInteractions(handler); } + @Test + public void requestMatchersWhenMultipleDispatcherServletsAndPathBeanThenAllows() throws Exception { + this.spring.register(MvcRequestMatcherBuilderConfig.class, BasicController.class) + .postProcessor((context) -> context.getServletContext() + .addServlet("otherDispatcherServlet", DispatcherServlet.class) + .addMapping("/mvc")) + .autowire(); + this.mvc.perform(get("/mvc/path").servletPath("/mvc").with(user("user"))).andExpect(status().isOk()); + this.mvc.perform(get("/mvc/path").servletPath("/mvc").with(user("user").roles("DENIED"))) + .andExpect(status().isForbidden()); + this.mvc.perform(get("/path").with(user("user"))).andExpect(status().isForbidden()); + } + @Configuration @EnableWebSecurity static class GrantedAuthorityDefaultHasRoleConfig { @@ -1262,6 +1278,10 @@ void rootGet() { void rootPost() { } + @GetMapping("/path") + void path() { + } + } @Configuration @@ -1317,4 +1337,24 @@ SecurityObservationSettings observabilityDefaults() { } + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class MvcRequestMatcherBuilderConfig { + + @Bean + RequestMatcherBuilder servletPath() { + return PathPatternRequestMatcher.builder().servletPath("/mvc"); + } + + @Bean + SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/path").hasRole("USER")) + .httpBasic(withDefaults()); + + return http.build(); + } + + } + } diff --git a/docs/modules/ROOT/pages/migration-7/web.adoc b/docs/modules/ROOT/pages/migration-7/web.adoc index 024d5604494..1c5ac51566c 100644 --- a/docs/modules/ROOT/pages/migration-7/web.adoc +++ b/docs/modules/ROOT/pages/migration-7/web.adoc @@ -102,3 +102,23 @@ Xml:: ---- ====== + +== Favor PathPatternRequestMatcher + +`MvcRequestMatcher` is deprecated in 6.5. +XML, Kotlin, and Java will all favor `PathPatternRequestMatcher` by default in 7.0. + +If you aren't already publishing a `RequestMatcherBuilder` bean, you can prepare for this change in defaults by publishing the following bean: + +[source,java] +---- +@Bean +RequestMatcherBuilder favorPathPattern() { + return ServletRequestMatcherBuilders.deducePath(); +} +---- + +This static factory aligns with the Spring Security defaults for request matchers except that it uses `PathPatternRequestMatcher` instead. +It reflects what the default will be in Spring Security 7. + +If this creates problems for you and you cannot use this bean at the moment, then change each of your `String` URI authorization rules to xref:servlet/authorization/authorize-http-requests.adoc#security-matchers[use a `RequestMatcher`]. diff --git a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc index 4eaf5f3d5ee..9e0518f8e04 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc @@ -577,15 +577,11 @@ http { ====== [[match-by-mvc]] -=== Using an MvcRequestMatcher +=== Matching by Servlet Path Generally speaking, you can use `requestMatchers(String)` as demonstrated above. -However, if you map Spring MVC to a different servlet path, then you need to account for that in your security configuration. - -For example, if Spring MVC is mapped to `/spring-mvc` instead of `/` (the default), then you may have an endpoint like `/spring-mvc/my/controller` that you want to authorize. - -You need to use `MvcRequestMatcher` to split the servlet path and the controller path in your configuration like so: +However, if you have authorization rules from multiple servlets, you need to specify those: .Match by MvcRequestMatcher [tabs] @@ -594,16 +590,14 @@ Java:: + [source,java,role="primary"] ---- -@Bean -MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) { - return new MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc"); -} +import static org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.servletPath; @Bean -SecurityFilterChain appEndpoints(HttpSecurity http, MvcRequestMatcher.Builder mvc) { +SecurityFilterChain appEndpoints(HttpSecurity http) { http .authorizeHttpRequests((authorize) -> authorize - .requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller") + .requestMatchers(servletPath("/spring-mvc").pattern("/admin/**")).hasAuthority("admin") + .requestMatchers(servletPath("/spring-mvc").pattern("/my/controller/**")).hasAuthority("controller") .anyRequest().authenticated() ); @@ -616,17 +610,15 @@ Kotlin:: [source,kotlin,role="secondary"] ---- @Bean -fun mvc(introspector: HandlerMappingIntrospector): MvcRequestMatcher.Builder = - MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc"); - -@Bean -fun appEndpoints(http: HttpSecurity, mvc: MvcRequestMatcher.Builder): SecurityFilterChain = +fun appEndpoints(http: HttpSecurity): SecurityFilterChain { http { authorizeHttpRequests { - authorize(mvc.pattern("/my/controller/**"), hasAuthority("controller")) + authorize("/spring-mvc", "/admin/**", hasAuthority("admin")) + authorize("/spring-mvc", "/my/controller/**", hasAuthority("controller")) authorize(anyRequest, authenticated) } } +} ---- Xml:: @@ -634,16 +626,91 @@ Xml:: [source,xml,role="secondary"] ---- + ---- ====== -This need can arise in at least two different ways: +The primary reason for this is that Spring MVC URIs are relative to the servlet. +In other words, an authorization rule usually doesn't include the servlet path. + +Other URIs may include the servlet path. +Because of that, the best practice is to always supply the servlet path when your application has more than one servlet. + +==== But I do only have one servlet, why is Spring Security complaining? + +Sometimes, application containers include additional servlets. +This can cause some confusion when you know as the developer that the only authorization rules you are writing are for your one servlet (Spring MVC, for example) + +In this case, in the Java DSL you can publish a `ServletRequestMatcherBuilders#servletPath` as a `@Bean` and Spring Security will use it for all URIs. + +For example, the above Java sample can be rewritten as: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +RequestMatcherBuilder mvc() { + return ServeltRequestMatcherBuilders.servletPath("/spring-mvc"); +} + +@Bean +SecurityFilterChain security(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/admin/**").hasAuthority("admin") + .requestMatchers("/my/controller/**").hasAuthority("controller") + .anyRequest().authenticated() + ); + return http.build(); +} +---- +====== + +[TIP] +==== +If you are a Spring Boot application, you may be able to publish the above bean like so: + +[source,java] +---- +@Bean +RequestMatcherBuilder mvc(WebMvcProperties properties) { + return ServletRequestMatcherBuilders.servletPath(proeprties.getServlet().getPath()); +} +---- +==== + +This same strategy is useful when it comes to static resources. +You can permit these by using Spring Boot's `RequestMatchers` static factory like so: + +[tabs] +====== +Java:: ++ +[source,java] +---- +@Bean +RequestMatcherBuilder mvc() { + return ServletRequestMatcherBuilders.servletPath("/mvc"); +} + +@Bean +SecurityFilterChain security(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers("/my/**", "/app/**", "/requests/**").hasAuthority("app") + ) +} +---- +====== -* If you use the `spring.mvc.servlet.path` Boot property to change the default path (`/`) to something else -* If you register more than one Spring MVC `DispatcherServlet` (thus requiring that one of them not be the default path) +Since `atCommonLocations` returns instances of `RequestMatcher`, this technique allows you to have all your `String`-based authorizations relative to the globally-configured `ServletRequestMatcherBuilders#servletPath`. [[match-by-custom]] === Using a Custom Matcher diff --git a/web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRegistrationsSupport.java b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRegistrationsSupport.java new file mode 100644 index 00000000000..eb22aea69d6 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRegistrationsSupport.java @@ -0,0 +1,77 @@ +/* + * 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.web.servlet.util.matcher; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; + +import org.springframework.util.ClassUtils; + +class ServletRegistrationsSupport { + + private final Collection registrations; + + ServletRegistrationsSupport(ServletContext servletContext) { + Map registrations = servletContext.getServletRegistrations(); + Collection mappings = new ArrayList<>(); + for (Map.Entry entry : registrations.entrySet()) { + if (!entry.getValue().getMappings().isEmpty()) { + for (String mapping : entry.getValue().getMappings()) { + mappings.add(new RegistrationMapping(entry.getValue(), mapping)); + } + } + } + this.registrations = mappings; + } + + Collection dispatcherServletMappings() { + Collection mappings = new ArrayList<>(); + for (RegistrationMapping registration : this.registrations) { + if (registration.isDispatcherServlet()) { + mappings.add(registration); + } + } + return mappings; + } + + Collection mappings() { + return this.registrations; + } + + record RegistrationMapping(ServletRegistration registration, String mapping) { + boolean isDispatcherServlet() { + Class dispatcherServlet = ClassUtils + .resolveClassName("org.springframework.web.servlet.DispatcherServlet", null); + try { + Class clazz = Class.forName(this.registration.getClassName()); + return dispatcherServlet.isAssignableFrom(clazz); + } + catch (ClassNotFoundException ex) { + return false; + } + } + + boolean isDefault() { + return "/".equals(this.mapping); + } + } + +} diff --git a/web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuilders.java b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuilders.java new file mode 100644 index 00000000000..43e0fe929dc --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuilders.java @@ -0,0 +1,249 @@ +/* + * 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.web.servlet.util.matcher; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.ServletRegistrationsSupport.RegistrationMapping; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; +import org.springframework.util.Assert; +import org.springframework.web.servlet.DispatcherServlet; + +/** + * A {@link RequestMatcherBuilder} for specifying the servlet path separately from the + * rest of the URI. This is helpful when you have more than one servlet. + * + *

+ * For example, if Spring MVC is deployed to `/mvc` and another servlet to `/other`, then + * you can do + *

+ * + * + * http + * .authorizeHttpRequests((authorize) -> authorize + * .requestMatchers(servletPath("/mvc").pattern("/my/**", "/controller/**", "/endpoints/**")).hasAuthority(... + * .requestMatchers(servletPath("/other").pattern("/my/**", "/non-mvc/**", "/endpoints/**")).hasAuthority(... + * } + * ... + * + * + * @author Josh Cummings + * @since 6.5 + */ +public final class ServletRequestMatcherBuilders { + + private ServletRequestMatcherBuilders() { + } + + /** + * Create {@link RequestMatcher}s that will only match URIs against the default + * servlet. + * @return a {@link ServletRequestMatcherBuilders} that matches URIs mapped to the + * default servlet + */ + public static RequestMatcherBuilder defaultServlet() { + return servletPathInternal(""); + } + + /** + * Create {@link RequestMatcher}s that will only match URIs against the given servlet + * path + * + *

+ * The path must be of the format {@code /path}. It should not end in `/` or `/*`, nor + * should it be a file extension. To specify the default servlet, use + * {@link #defaultServlet()}. + *

+ * @return a {@link ServletRequestMatcherBuilders} that matches URIs mapped to the + * given servlet path + */ + public static RequestMatcherBuilder servletPath(String servletPath) { + Assert.notNull(servletPath, "servletPath cannot be null"); + Assert.isTrue(servletPath.startsWith("/"), "servletPath must start with '/'"); + Assert.isTrue(!servletPath.endsWith("/"), "servletPath must not end with '/'"); + Assert.isTrue(!servletPath.endsWith("/*"), "servletPath must not end with '/*'"); + return servletPathInternal(servletPath); + } + + private static RequestMatcherBuilder servletPathInternal(String servletPath) { + return (method, pattern) -> { + Assert.notNull(pattern, "pattern cannot be null"); + Assert.isTrue(pattern.startsWith("/"), "pattern must start with '/'"); + return PathPatternRequestMatcher.builder().servletPath(servletPath).pattern(method, pattern); + }; + } + + /** + * Create {@link RequestMatcher}s that will deduce the servlet path by testing the + * given patterns as relative and absolute. If the target servlet is + * {@link DispatcherServlet}, then it tests the pattern as relative to the servlet + * path; otherwise, it tests the pattern as absolute + * @return a {@link ServletRequestMatcherBuilders} that deduces the servlet path at + * request time + */ + public static RequestMatcherBuilder servletPathDeducing() { + return (method, pattern) -> { + Assert.notNull(pattern, "pattern cannot be null"); + Assert.isTrue(pattern.startsWith("/"), "pattern must start with '/'"); + return new PathDeducingRequestMatcher(method, pattern); + }; + } + + static final class PathDeducingRequestMatcher implements RequestMatcher { + + private static final RequestMatcher isMockMvc = (request) -> request + .getAttribute("org.springframework.test.web.servlet.MockMvc.MVC_RESULT_ATTRIBUTE") != null; + + private static final RequestMatcher isDispatcherServlet = (request) -> { + String name = request.getHttpServletMapping().getServletName(); + ServletContext servletContext = request.getServletContext(); + ServletRegistration registration = servletContext.getServletRegistration(name); + Assert.notNull(registration, () -> computeErrorMessage(servletContext.getServletRegistrations().values())); + String mapping = request.getHttpServletMapping().getPattern(); + return new RegistrationMapping(registration, mapping).isDispatcherServlet(); + }; + + private final Map delegates = new ConcurrentHashMap<>(); + + private HttpMethod method; + + private String pattern; + + PathDeducingRequestMatcher(HttpMethod method, String pattern) { + this.method = method; + this.pattern = pattern; + } + + RequestMatcher requestMatcher(HttpServletRequest request) { + return this.delegates.computeIfAbsent(request.getServletContext(), (servletContext) -> { + PathPatternRequestMatcher absolute = PathPatternRequestMatcher.builder() + .pattern(this.method, this.pattern); + PathPatternRequestMatcher relative = PathPatternRequestMatcher.builder() + .pattern(this.method, this.pattern); + ServletRegistrationsSupport registrations = new ServletRegistrationsSupport(servletContext); + Collection mappings = registrations.mappings(); + if (mappings.isEmpty()) { + relative.setServletPath(PathPatternRequestMatcher.ANY_SERVLET); + return new EitherRequestMatcher(relative, absolute, isMockMvc); + } + Collection dispatcherServletMappings = registrations.dispatcherServletMappings(); + if (dispatcherServletMappings.isEmpty()) { + relative.setServletPath(PathPatternRequestMatcher.ANY_SERVLET); + return new EitherRequestMatcher(relative, absolute, isMockMvc); + } + if (dispatcherServletMappings.size() > 1) { + String errorMessage = computeErrorMessage(servletContext.getServletRegistrations().values()); + throw new IllegalArgumentException(errorMessage); + } + RegistrationMapping dispatcherServlet = dispatcherServletMappings.iterator().next(); + if (mappings.size() > 1 && !dispatcherServlet.isDefault()) { + String errorMessage = computeErrorMessage(servletContext.getServletRegistrations().values()); + throw new IllegalArgumentException(errorMessage); + } + if (dispatcherServlet.isDefault()) { + relative.setServletPath(""); + if (mappings.size() == 1) { + return relative; + } + return new EitherRequestMatcher(relative, absolute, + new OrRequestMatcher(isMockMvc, isDispatcherServlet)); + } + String mapping = dispatcherServlet.mapping(); + relative.setServletPath(mapping.substring(0, mapping.length() - 2)); + return relative; + }); + } + + @Override + public boolean matches(HttpServletRequest request) { + return matcher(request).isMatch(); + } + + @Override + public MatchResult matcher(HttpServletRequest request) { + return requestMatcher(request).matcher(request); + } + + private static String computeErrorMessage(Collection registrations) { + String template = """ + This method cannot decide whether these patterns are Spring MVC patterns or not. \ + This is because there is more than one mappable servlet in your servlet context: %s. + + To address this, please create one ServletRequestMatcherBuilder#servletPath for each servlet that has \ + authorized endpoints and use them to construct request matchers manually. \ + If all your URIs are unambiguous, then you can simply publish one ServletRequestMatcherBuilders#servletPath as \ + a @Bean and Spring Security will use it for all URIs"""; + Map> mappings = new LinkedHashMap<>(); + for (ServletRegistration registration : registrations) { + mappings.put(registration.getClassName(), registration.getMappings()); + } + return String.format(template, mappings); + } + + @Override + public String toString() { + return "PathDeducingRequestMatcher [delegates = " + this.delegates + "]"; + } + + } + + static class EitherRequestMatcher implements RequestMatcher { + + final RequestMatcher right; + + final RequestMatcher left; + + final RequestMatcher test; + + EitherRequestMatcher(RequestMatcher right, RequestMatcher left, RequestMatcher test) { + this.left = left; + this.right = right; + this.test = test; + } + + RequestMatcher requestMatcher(HttpServletRequest request) { + return this.test.matches(request) ? this.right : this.left; + } + + @Override + public boolean matches(HttpServletRequest request) { + return requestMatcher(request).matches(request); + } + + @Override + public MatchResult matcher(HttpServletRequest request) { + return requestMatcher(request).matcher(request); + } + + @Override + public String toString() { + return "Either [" + "left = " + this.left + ", right = " + this.right + "]"; + } + + } + +} diff --git a/web/src/test/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuildersTests.java b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuildersTests.java new file mode 100644 index 00000000000..a37294e78a0 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/ServletRequestMatcherBuildersTests.java @@ -0,0 +1,171 @@ +/* + * 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.web.servlet.util.matcher; + +import jakarta.servlet.Servlet; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.servlet.MockServletContext; +import org.springframework.security.web.servlet.TestMockHttpServletMappings; +import org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.EitherRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.PathDeducingRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; +import org.springframework.web.servlet.DispatcherServlet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class ServletRequestMatcherBuildersTests { + + @Test + void patternWhenServletPathThenUsesPathPattern() { + RequestMatcherBuilder builder = ServletRequestMatcherBuilders.servletPath("/servlet/path"); + RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint"); + assertThat(requestMatcher).isInstanceOf(PathPatternRequestMatcher.class); + } + + @Test + void patternWhenDefaultServletThenUsesPathPattern() { + RequestMatcherBuilder builder = ServletRequestMatcherBuilders.defaultServlet(); + RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint"); + assertThat(requestMatcher).isInstanceOf(PathPatternRequestMatcher.class); + } + + @Test + void patternWhenServletPathDeducingThenUsesComposite() { + RequestMatcherBuilder builder = ServletRequestMatcherBuilders.servletPathDeducing(); + RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint"); + assertThat(requestMatcher).isInstanceOf(PathDeducingRequestMatcher.class); + } + + @Test + void requestMatchersWhenAmbiguousServletsThenException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/"); + servletContext.addServlet("servletTwo", DispatcherServlet.class).addMapping("/servlet/*"); + RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/**"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatcher.matches(new MockHttpServletRequest(servletContext))); + } + + @Test + void requestMatchersWhenMultipleDispatcherServletMappingsThenException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/", "/mvc/*"); + RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/**"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatcher.matcher(new MockHttpServletRequest(servletContext))); + } + + @Test + void requestMatchersWhenPathDispatcherServletAndOtherServletsThenException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/**"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatcher.matcher(new MockHttpServletRequest(servletContext))); + } + + @Test + void requestMatchersWhenUnmappableServletsThenSkips() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/"); + servletContext.addServlet("servletTwo", Servlet.class); + PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders + .servletPathDeducing() + .pattern("/**"); + RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext)); + assertThat(deduced).isInstanceOf(PathPatternRequestMatcher.class); + } + + @Test + void requestMatchersWhenOnlyDispatcherServletThenAllows() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders + .servletPathDeducing() + .pattern("/**"); + RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext)); + assertThat(deduced).isInstanceOf(PathPatternRequestMatcher.class); + } + + @Test + void requestMatchersWhenImplicitServletsThenAllows() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("defaultServlet", Servlet.class); + servletContext.addServlet("jspServlet", Servlet.class).addMapping("*.jsp", "*.jspx"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/"); + PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders + .servletPathDeducing() + .pattern("/**"); + RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext)); + assertThat(deduced).isInstanceOf(EitherRequestMatcher.class); + } + + @Test + void requestMatchersWhenPathBasedNonDispatcherServletThenAllows() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("path", Servlet.class).addMapping("/services/*"); + servletContext.addServlet("default", DispatcherServlet.class).addMapping("/"); + PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders + .servletPathDeducing() + .pattern("/services/*"); + RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext)); + assertThat(deduced).isInstanceOf(EitherRequestMatcher.class); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint"); + request.setHttpServletMapping(TestMockHttpServletMappings.defaultMapping()); + request.setServletPath(""); + assertThat(deduced.matcher(request).isMatch()).isTrue(); + request.setHttpServletMapping(TestMockHttpServletMappings.path(request, "/services")); + request.setServletPath("/services"); + request.setPathInfo("/endpoint"); + assertThat(deduced.matcher(request).isMatch()).isTrue(); + } + + @Test + void matchesWhenDispatcherServletThenMvc() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", DispatcherServlet.class).addMapping("/"); + servletContext.addServlet("path", Servlet.class).addMapping("/services/*"); + PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders + .servletPathDeducing() + .pattern("/services/*"); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint"); + request.setHttpServletMapping(TestMockHttpServletMappings.defaultMapping()); + EitherRequestMatcher either = (EitherRequestMatcher) requestMatcher.requestMatcher(request); + RequestMatcher deduced = either.requestMatcher(request); + assertThat(deduced).isEqualTo(either.right); + request.setHttpServletMapping(TestMockHttpServletMappings.path(request, "/services")); + deduced = either.requestMatcher(request); + assertThat(deduced).isEqualTo(either.left); + } + + @Test + void matchesWhenNoMappingThenException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", DispatcherServlet.class).addMapping("/"); + servletContext.addServlet("path", Servlet.class).addMapping("/services/*"); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint"); + RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/services/*"); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> requestMatcher.matcher(request)); + } + +} From 772f0cc7410a2c1058f1e9e260f2c5c3bdf6694c Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:43:08 -0700 Subject: [PATCH 3/4] Deprecate MvcRequestMatcher --- .../web/servlet/util/matcher/MvcRequestMatcher.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/main/java/org/springframework/security/web/servlet/util/matcher/MvcRequestMatcher.java b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/MvcRequestMatcher.java index 9576f0533e6..e827cfb5b17 100644 --- a/web/src/main/java/org/springframework/security/web/servlet/util/matcher/MvcRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/MvcRequestMatcher.java @@ -23,6 +23,7 @@ import org.springframework.http.HttpMethod; import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; import org.springframework.security.web.util.matcher.RequestVariablesExtractor; import org.springframework.util.AntPathMatcher; import org.springframework.util.PathMatcher; @@ -46,7 +47,9 @@ * @author EddĂș MelĂ©ndez * @author Evgeniy Cheban * @since 4.1.1 + * @deprecated Use {@link PathPatternRequestMatcher} instead. */ +@Deprecated public class MvcRequestMatcher implements RequestMatcher, RequestVariablesExtractor { private final DefaultMatcher defaultMatcher = new DefaultMatcher(); @@ -197,7 +200,7 @@ public MatchResult matcher(HttpServletRequest request) { * @author Marcus Da Coregio * @since 5.8 */ - public static final class Builder { + public static final class Builder implements RequestMatcherBuilder { private final HandlerMappingIntrospector introspector; @@ -237,6 +240,7 @@ public MvcRequestMatcher pattern(String pattern) { * @param pattern the patterns used to match * @return the generated {@link MvcRequestMatcher} */ + @Override public MvcRequestMatcher pattern(HttpMethod method, String pattern) { MvcRequestMatcher mvcRequestMatcher = new MvcRequestMatcher(this.introspector, pattern); mvcRequestMatcher.setServletPath(this.servletPath); From 25cfbe85d0ed7764757c3a8c6b360dbaf0b40fd5 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:43:21 -0700 Subject: [PATCH 4/4] Use PathPatternRequestMatcher by Default --- .../web/AbstractRequestMatcherRegistry.java | 48 +++++++++------ .../security/config/http/MatcherType.java | 59 ++++++++++++++++++- .../web/AuthorizeHttpRequestsDsl.kt | 21 +++++-- .../config/FilterChainProxyConfigTests.java | 4 +- .../AbstractRequestMatcherRegistryTests.java | 15 +++-- .../servlet/appendix/namespace/http.adoc | 2 +- .../ROOT/pages/servlet/integrations/mvc.adoc | 21 ++++--- etc/checkstyle/checkstyle-suppressions.xml | 2 + .../security/web/FilterInvocation.java | 10 +++- 9 files changed, 144 insertions(+), 38 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java index 2181da01aea..3719e22c88a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java @@ -43,6 +43,7 @@ import org.springframework.security.config.annotation.web.ServletRegistrationsSupport.RegistrationMapping; import org.springframework.security.config.annotation.web.configurers.AbstractConfigAttributeRequestMatcherRegistry; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher; @@ -53,8 +54,8 @@ import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.web.context.WebApplicationContext; -import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.handler.HandlerMappingIntrospector; +import org.springframework.web.util.pattern.PathPatternParser; /** * A base class for registering {@link RequestMatcher}'s. For example, it might allow for @@ -234,7 +235,7 @@ private boolean anyPathsDontStartWithLeadingSlash(String... patterns) { return false; } - private RequestMatcher resolve(AntPathRequestMatcher ant, MvcRequestMatcher mvc, ServletContext servletContext) { + private RequestMatcher resolve(AntPathRequestMatcher ant, RequestMatcher mvc, ServletContext servletContext) { ServletRegistrationsSupport registrations = new ServletRegistrationsSupport(servletContext); Collection mappings = registrations.mappings(); if (mappings.isEmpty()) { @@ -280,10 +281,10 @@ private static String computeErrorMessage(Collection - * If the {@link HandlerMappingIntrospector} is available in the classpath, maps to an - * {@link MvcRequestMatcher} that does not care which {@link HttpMethod} is used. This - * matcher will use the same rules that Spring MVC uses for matching. For example, - * often times a mapping of the path "/path" will match on "/path", "/path/", + * If the {@link HandlerMappingIntrospector} is available in the classpath, maps to a + * {@link PathPatternRequestMatcher} that does not care which {@link HttpMethod} is + * used. This matcher will use the same rules that Spring MVC uses for matching. For + * example, often times a mapping of the path "/path" will match on "/path", "/path/", * "/path.html", etc. If the {@link HandlerMappingIntrospector} is not available, maps * to an {@link AntPathRequestMatcher}. *

@@ -408,8 +409,26 @@ class DefaultRequestMatcherBuilder implements RequestMatcherBuilder { @Override public RequestMatcher pattern(HttpMethod method, String pattern) { + Assert.state(!AbstractRequestMatcherRegistry.this.anyRequestConfigured, + "Can't configure mvcMatchers after anyRequest"); AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null); - MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0); + RequestMatcher mvc; + if (!AbstractRequestMatcherRegistry.this.context.containsBean(HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME)) { + throw new NoSuchBeanDefinitionException("A Bean named " + HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME + + " of type " + HandlerMappingIntrospector.class.getName() + + " is required to use MvcRequestMatcher. Please ensure Spring Security & Spring MVC are configured in a shared ApplicationContext."); + } + HandlerMappingIntrospector introspector = AbstractRequestMatcherRegistry.this.context + .getBean(HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME, HandlerMappingIntrospector.class); + if (introspector.allHandlerMappingsUsePathPatternParser()) { + PathPatternParser pathPatternParser = AbstractRequestMatcherRegistry.this.context + .getBeanProvider(PathPatternParser.class) + .getIfUnique(() -> PathPatternParser.defaultInstance); + mvc = PathPatternRequestMatcher.withPathPatternParser(pathPatternParser).pattern(method, pattern); + } + else { + mvc = createMvcMatchers(method, pattern).get(0); + } return new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant); } @@ -466,13 +485,8 @@ public boolean matches(HttpServletRequest request) { ServletRegistration registration = request.getServletContext().getServletRegistration(name); Assert.notNull(registration, () -> computeErrorMessage(request.getServletContext().getServletRegistrations().values())); - try { - Class clazz = Class.forName(registration.getClassName()); - return DispatcherServlet.class.isAssignableFrom(clazz); - } - catch (ClassNotFoundException ex) { - return false; - } + return new RegistrationMapping(registration, request.getHttpServletMapping().getPattern()) + .isDispatcherServlet(); } } @@ -481,15 +495,15 @@ static class DispatcherServletDelegatingRequestMatcher implements RequestMatcher private final AntPathRequestMatcher ant; - private final MvcRequestMatcher mvc; + private final RequestMatcher mvc; private final RequestMatcher dispatcherServlet; - DispatcherServletDelegatingRequestMatcher(AntPathRequestMatcher ant, MvcRequestMatcher mvc) { + DispatcherServletDelegatingRequestMatcher(AntPathRequestMatcher ant, RequestMatcher mvc) { this(ant, mvc, new OrRequestMatcher(new MockMvcRequestMatcher(), new DispatcherServletRequestMatcher())); } - DispatcherServletDelegatingRequestMatcher(AntPathRequestMatcher ant, MvcRequestMatcher mvc, + DispatcherServletDelegatingRequestMatcher(AntPathRequestMatcher ant, RequestMatcher mvc, RequestMatcher dispatcherServlet) { this.ant = ant; this.mvc = mvc; diff --git a/config/src/main/java/org/springframework/security/config/http/MatcherType.java b/config/src/main/java/org/springframework/security/config/http/MatcherType.java index 3755cb84e8e..1a6448d93a0 100644 --- a/config/src/main/java/org/springframework/security/config/http/MatcherType.java +++ b/config/src/main/java/org/springframework/security/config/http/MatcherType.java @@ -16,20 +16,25 @@ package org.springframework.security.config.http; +import jakarta.servlet.http.HttpServletRequest; import org.w3c.dom.Element; +import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.beans.factory.xml.ParserContext; import org.springframework.http.HttpMethod; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.security.web.util.matcher.RegexRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; +import org.springframework.web.util.pattern.PathPatternParser; /** * Defines the {@link RequestMatcher} types supported by the namespace. @@ -40,7 +45,7 @@ public enum MatcherType { ant(AntPathRequestMatcher.class), regex(RegexRequestMatcher.class), ciRegex(RegexRequestMatcher.class), - mvc(MvcRequestMatcher.class); + mvc(MvcRequestMatcherFactoryBean.class); private static final String HANDLER_MAPPING_INTROSPECTOR = "org.springframework.web.servlet.handler.HandlerMappingIntrospector"; @@ -100,4 +105,56 @@ static MatcherType fromElementOrMvc(Element elt) { return MatcherType.fromElement(elt); } + private static class MvcRequestMatcherFactoryBean implements FactoryBean, RequestMatcher { + + private final HandlerMappingIntrospector introspector; + + private final String pattern; + + private PathPatternParser pathPatternParser = PathPatternParser.defaultInstance; + + private String servletPath; + + private HttpMethod method; + + public MvcRequestMatcherFactoryBean(HandlerMappingIntrospector introspector, String pattern) { + this.introspector = introspector; + this.pattern = pattern; + } + + @Override + public RequestMatcher getObject() { + if (this.introspector.allHandlerMappingsUsePathPatternParser()) { + return PathPatternRequestMatcher.withPathPatternParser(this.pathPatternParser) + .servletPath(this.servletPath) + .pattern(this.method, this.pattern); + } + return new MvcRequestMatcher.Builder(this.introspector).servletPath(this.servletPath) + .pattern(this.method, this.pattern); + } + + @Override + public Class getObjectType() { + return RequestMatcher.class; + } + + @Override + public boolean matches(HttpServletRequest request) { + return getObject().matches(request); + } + + public void setPathPatternParser(PathPatternParser pathPatternParser) { + this.pathPatternParser = pathPatternParser; + } + + public void setServletPath(String servletPath) { + this.servletPath = servletPath; + } + + public void setMethod(HttpMethod method) { + this.method = method; + } + + } + } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt index 0133670a18f..ba90c2d8c47 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt @@ -32,10 +32,12 @@ import org.springframework.security.web.access.IpAddressAuthorizationManager import org.springframework.security.web.access.intercept.AuthorizationFilter import org.springframework.security.web.access.intercept.RequestAuthorizationContext import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher import org.springframework.security.web.util.matcher.AnyRequestMatcher import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.util.ClassUtils import org.springframework.web.servlet.handler.HandlerMappingIntrospector +import org.springframework.web.util.pattern.PathPatternParser import java.util.function.Supplier /** @@ -292,11 +294,20 @@ class AuthorizeHttpRequestsDsl : AbstractRequestMatcherDsl { PatternType.ANT -> requests.requestMatchers(rule.httpMethod, rule.pattern).access(rule.rule) PatternType.MVC -> { val introspector = requests.applicationContext.getBean(HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME, HandlerMappingIntrospector::class.java) - val mvcMatcher = MvcRequestMatcher.Builder(introspector) - .servletPath(rule.servletPath) - .pattern(rule.pattern) - mvcMatcher.setMethod(rule.httpMethod) - requests.requestMatchers(mvcMatcher).access(rule.rule) + if (introspector.allHandlerMappingsUsePathPatternParser()) { + val pathPatternParser: PathPatternParser = requests.applicationContext.getBeanProvider(PathPatternParser::class.java) + .getIfUnique({PathPatternParser.defaultInstance}) + val mvcMatcher = PathPatternRequestMatcher.withPathPatternParser(pathPatternParser) + .servletPath(rule.servletPath) + .pattern(rule.httpMethod, rule.pattern) + requests.requestMatchers(mvcMatcher).access(rule.rule) + } else { + val mvcMatcher = MvcRequestMatcher.Builder(introspector) + .servletPath(rule.servletPath) + .pattern(rule.pattern) + mvcMatcher.setMethod(rule.httpMethod) + requests.requestMatchers(mvcMatcher).access(rule.rule) + } } } } diff --git a/config/src/test/java/org/springframework/security/config/FilterChainProxyConfigTests.java b/config/src/test/java/org/springframework/security/config/FilterChainProxyConfigTests.java index e2f81e3e17d..cdeec1f3994 100644 --- a/config/src/test/java/org/springframework/security/config/FilterChainProxyConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/FilterChainProxyConfigTests.java @@ -39,6 +39,7 @@ import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.util.pattern.PathPattern; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -120,7 +121,8 @@ public void mixingPatternsAndPlaceholdersDoesntCauseOrderingIssues() { private String getPattern(SecurityFilterChain chain) { RequestMatcher requestMatcher = ((DefaultSecurityFilterChain) chain).getRequestMatcher(); - return (String) ReflectionTestUtils.getField(requestMatcher, "pattern"); + Object pattern = ReflectionTestUtils.getField(requestMatcher, "pattern"); + return (pattern instanceof PathPattern) ? pattern.toString() : (String) pattern; } private void checkPathAndFilterOrder(FilterChainProxy filterChainProxy) { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java index 4a7ae59d07e..b0e8fda23d0 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java @@ -21,6 +21,7 @@ import jakarta.servlet.DispatcherType; import jakarta.servlet.Servlet; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -39,6 +40,7 @@ import org.springframework.security.web.servlet.MockServletContext; import org.springframework.security.web.servlet.TestMockHttpServletMappings; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher; import org.springframework.security.web.util.matcher.RegexRequestMatcher; @@ -49,10 +51,13 @@ import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -91,11 +96,13 @@ public void setUp() { given(this.context.getServletContext()).willReturn(MockServletContext.mvc()); ObjectProvider requestMatcherFactories = new ObjectProvider<>() { @Override - public RequestMatcherBuilder getObject() throws BeansException { + public @NotNull RequestMatcherBuilder getObject() throws BeansException { return AbstractRequestMatcherRegistryTests.this.matcherRegistry.new DefaultRequestMatcherBuilder(); } }; given(this.context.getBeanProvider(RequestMatcherBuilder.class)).willReturn(requestMatcherFactories); + HandlerMappingIntrospector introspector = mock(HandlerMappingIntrospector.class); + given(this.context.getBean(any(), eq(HandlerMappingIntrospector.class))).willReturn(introspector); this.matcherRegistry.setApplicationContext(this.context); mockMvcIntrospector(true); } @@ -231,7 +238,7 @@ public void requestMatchersWhenNoDispatcherServletMockMvcThenMvcRequestMatcherTy assertThat(requestMatchers).hasSize(1); assertThat(requestMatchers.get(0)).asInstanceOf(type(DispatcherServletDelegatingRequestMatcher.class)) .extracting((matcher) -> matcher.requestMatcher(request)) - .isInstanceOf(MvcRequestMatcher.class); + .isInstanceOf(PathPatternRequestMatcher.class); servletContext.addServlet("servletOne", Servlet.class).addMapping("/one"); servletContext.addServlet("servletTwo", Servlet.class).addMapping("/two"); requestMatchers = this.matcherRegistry.requestMatchers("/**"); @@ -239,7 +246,7 @@ public void requestMatchersWhenNoDispatcherServletMockMvcThenMvcRequestMatcherTy assertThat(requestMatchers).hasSize(1); assertThat(requestMatchers.get(0)).asInstanceOf(type(DispatcherServletDelegatingRequestMatcher.class)) .extracting((matcher) -> matcher.requestMatcher(request)) - .isInstanceOf(MvcRequestMatcher.class); + .isInstanceOf(PathPatternRequestMatcher.class); servletContext.addServlet("servletOne", Servlet.class); servletContext.addServlet("servletTwo", Servlet.class); requestMatchers = this.matcherRegistry.requestMatchers("/**"); @@ -247,7 +254,7 @@ public void requestMatchersWhenNoDispatcherServletMockMvcThenMvcRequestMatcherTy assertThat(requestMatchers).hasSize(1); assertThat(requestMatchers.get(0)).asInstanceOf(type(DispatcherServletDelegatingRequestMatcher.class)) .extracting((matcher) -> matcher.requestMatcher(request)) - .isInstanceOf(MvcRequestMatcher.class); + .isInstanceOf(PathPatternRequestMatcher.class); } } diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index d49c2f12db3..ef1e188ad68 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -124,7 +124,7 @@ Corresponds to the `realmName` property on `BasicAuthenticationEntryPoint`. Defines the `RequestMatcher` strategy used in the `FilterChainProxy` and the beans created by the `intercept-url` to match incoming requests. Options are currently `mvc`, `ant`, `regex` and `ciRegex`, for Spring MVC, ant, regular-expression and case-insensitive regular-expression respectively. A separate instance is created for each <> element using its <>, <> and <> attributes. -Ant paths are matched using an `AntPathRequestMatcher`, regular expressions are matched using a `RegexRequestMatcher` and for Spring MVC path matching the `MvcRequestMatcher` is used. +Ant paths are matched using an `AntPathRequestMatcher`, regular expressions are matched using a `RegexRequestMatcher` and for Spring MVC path matching the `PathPatternRequestMatcher` is used. See the Javadoc for these classes for more details on exactly how the matching is performed. MVC is the default strategy if Spring MVC is present in the classpath, if not, Ant paths are used. diff --git a/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc b/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc index 49314e8ba83..99b5aaa4577 100644 --- a/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc +++ b/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc @@ -22,13 +22,13 @@ This means that, if you use more advanced options, such as integrating with `Web ==== [[mvc-requestmatcher]] -== MvcRequestMatcher +== PathPatternRequestMatcher -Spring Security provides deep integration with how Spring MVC matches on URLs with `MvcRequestMatcher`. +Spring Security provides deep integration with how Spring MVC matches on URLs with `PahtPatternRequestMatcher`. This is helpful to ensure that your Security rules match the logic used to handle your requests. -To use `MvcRequestMatcher`, you must place the Spring Security Configuration in the same `ApplicationContext` as your `DispatcherServlet`. -This is necessary because Spring Security's `MvcRequestMatcher` expects a `HandlerMappingIntrospector` bean with the name of `mvcHandlerMappingIntrospector` to be registered by your Spring MVC configuration that is used to perform the matching. +To use `PathPatternRequestMatcher`, you may need to place the Spring Security Configuration in the same `ApplicationContext` as your `DispatcherServlet`. +This is necessary when using a custom `PathPatternParser` `@Bean`. In this case, both Spring MVC and Spring Security needs to be able to see this bean. For a `web.xml` file, this means that you should place your configuration in the `DispatcherServlet.xml`: @@ -196,18 +196,18 @@ Additionally, depending on our Spring MVC configuration, the `/admin` URL also m The problem is that our security rule protects only `/admin`. We could add additional rules for all the permutations of Spring MVC, but this would be quite verbose and tedious. -Fortunately, when using the `requestMatchers` DSL method, Spring Security automatically creates a `MvcRequestMatcher` if it detects that Spring MVC is available in the classpath. +Fortunately, when using the `requestMatchers` DSL method, Spring Security automatically creates a `PathPatternRequestMatcher` if it detects that Spring MVC is available in the classpath. Therefore, it will protect the same URLs that Spring MVC will match on by using Spring MVC to match on the URL. One common requirement when using Spring MVC is to specify the servlet path property. -For Java-based Configuration, you can use the `MvcRequestMatcher.Builder` to create multiple `MvcRequestMatcher` instances that share the same servlet path: +For Java-based Configuration, you can use the `PathPatternRequestMatcher.Builder` to create multiple `PathPatternRequestMatcher` instances that share the same servlet path: [source,java,role="primary"] ---- @Bean -public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception { - MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector).servletPath("/path"); +public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + PathPatternRequestMatcher.Builder mvcMatcherBuilder = new PathPatternRequestMatcher.Builder().servletPath("/path"); http .authorizeHttpRequests((authorize) -> authorize .requestMatchers(mvcMatcherBuilder.pattern("/admin")).hasRole("ADMIN") @@ -217,6 +217,11 @@ public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospe } ---- +[NOTE] +===== +For your convenience, you also can use `ServletRequestMatcherBuilders` which exposes an API that more directly reflects the Servlet spec. +===== + For Kotlin and XML, this happens when you specify the servlet path for each path like so: [tabs] diff --git a/etc/checkstyle/checkstyle-suppressions.xml b/etc/checkstyle/checkstyle-suppressions.xml index b368ce84e84..8fe152549db 100644 --- a/etc/checkstyle/checkstyle-suppressions.xml +++ b/etc/checkstyle/checkstyle-suppressions.xml @@ -38,6 +38,8 @@ + + diff --git a/web/src/main/java/org/springframework/security/web/FilterInvocation.java b/web/src/main/java/org/springframework/security/web/FilterInvocation.java index f9f86476c8c..9bc1a9a40b6 100644 --- a/web/src/main/java/org/springframework/security/web/FilterInvocation.java +++ b/web/src/main/java/org/springframework/security/web/FilterInvocation.java @@ -25,6 +25,7 @@ import java.lang.reflect.Proxy; import java.util.Collections; import java.util.Enumeration; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -178,6 +179,8 @@ static class DummyRequest extends HttpServletRequestWrapper { private final Map parameters = new LinkedHashMap<>(); + private final Map attributes = new HashMap<>(); + DummyRequest() { super(UNSUPPORTED_REQUEST); } @@ -189,7 +192,12 @@ public String getCharacterEncoding() { @Override public Object getAttribute(String attributeName) { - return null; + return this.attributes.get(attributeName); + } + + @Override + public void setAttribute(String name, Object value) { + this.attributes.put(name, value); } void setRequestURI(String requestURI) {