Skip to content

Commit 12f1cf2

Browse files
committed
Add ServletRequestMatcherBuilders
This static factory simplifes the creation of RequestMatchers that specify the servlet path. Closes gh-16430
1 parent 3a21bfb commit 12f1cf2

File tree

6 files changed

+333
-27
lines changed

6 files changed

+333
-27
lines changed

Diff for: config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java

+9-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -264,11 +264,14 @@ private RequestMatcher resolve(AntPathRequestMatcher ant, MvcRequestMatcher mvc,
264264
}
265265

266266
private static String computeErrorMessage(Collection<? extends ServletRegistration> registrations) {
267-
String template = "This method cannot decide whether these patterns are Spring MVC patterns or not. "
268-
+ "If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); "
269-
+ "otherwise, please use requestMatchers(AntPathRequestMatcher).\n\n"
270-
+ "This is because there is more than one mappable servlet in your servlet context: %s.\n\n"
271-
+ "For each MvcRequestMatcher, call MvcRequestMatcher#setServletPath to indicate the servlet path.";
267+
String template = """
268+
This method cannot decide whether these patterns are Spring MVC patterns or not. \
269+
This is because there is more than one mappable servlet in your servlet context: %s.
270+
271+
To address this, please create one ServletRequestMatcherBuilder#servletPath for each servlet that has \
272+
authorized endpoints and use them to construct request matchers manually. \
273+
If all your URIs are absolute, then you can simply use ServletRequestMatcherBuilders#requestPath \
274+
for all URIs""";
272275
Map<String, Collection<String>> mappings = new LinkedHashMap<>();
273276
for (ServletRegistration registration : registrations) {
274277
mappings.put(registration.getClassName(), registration.getMappings());

Diff for: config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java

+41
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
6565
import org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager;
6666
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
67+
import org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders;
68+
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
6769
import org.springframework.test.web.servlet.MockMvc;
6870
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
6971
import org.springframework.test.web.servlet.request.RequestPostProcessor;
@@ -72,6 +74,7 @@
7274
import org.springframework.web.bind.annotation.PostMapping;
7375
import org.springframework.web.bind.annotation.RequestMapping;
7476
import org.springframework.web.bind.annotation.RestController;
77+
import org.springframework.web.servlet.DispatcherServlet;
7578
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
7679
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
7780

@@ -667,6 +670,19 @@ public void getWhenExcludeAuthorizationObservationsThenUnobserved() throws Excep
667670
verifyNoInteractions(handler);
668671
}
669672

673+
@Test
674+
public void requestMatchersWhenMultipleDispatcherServletsAndPathBeanThenAllows() throws Exception {
675+
this.spring.register(MvcRequestMatcherBuilderConfig.class, BasicController.class)
676+
.postProcessor((context) -> context.getServletContext()
677+
.addServlet("otherDispatcherServlet", DispatcherServlet.class)
678+
.addMapping("/mvc"))
679+
.autowire();
680+
this.mvc.perform(get("/mvc/path").servletPath("/mvc").with(user("user"))).andExpect(status().isOk());
681+
this.mvc.perform(get("/mvc/path").servletPath("/mvc").with(user("user").roles("DENIED")))
682+
.andExpect(status().isForbidden());
683+
this.mvc.perform(get("/path").with(user("user"))).andExpect(status().isForbidden());
684+
}
685+
670686
@Configuration
671687
@EnableWebSecurity
672688
static class GrantedAuthorityDefaultHasRoleConfig {
@@ -1262,6 +1278,10 @@ void rootGet() {
12621278
void rootPost() {
12631279
}
12641280

1281+
@GetMapping("/path")
1282+
void path() {
1283+
}
1284+
12651285
}
12661286

12671287
@Configuration
@@ -1317,4 +1337,25 @@ SecurityObservationSettings observabilityDefaults() {
13171337

13181338
}
13191339

1340+
@Configuration
1341+
@EnableWebSecurity
1342+
@EnableWebMvc
1343+
static class MvcRequestMatcherBuilderConfig {
1344+
1345+
@Bean
1346+
SecurityFilterChain security(HttpSecurity http) throws Exception {
1347+
RequestMatcherBuilder mvc = ServletRequestMatcherBuilders.servletPath("/mvc");
1348+
// @formatter:off
1349+
http
1350+
.authorizeHttpRequests((authorize) -> authorize
1351+
.requestMatchers(mvc.pattern("/path/**")).hasRole("USER")
1352+
)
1353+
.httpBasic(withDefaults());
1354+
// @formatter:on
1355+
1356+
return http.build();
1357+
}
1358+
1359+
}
1360+
13201361
}

Diff for: docs/modules/ROOT/pages/migration-7/web.adoc

+9
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,12 @@ Xml::
102102
</b:bean>
103103
----
104104
======
105+
106+
== Use Absolute Authorization URIs
107+
108+
The Java DSL now requires that all URIs be absolute (less any context root).
109+
110+
This means any endpoints that are not part of the default servlet, xref:servlet/authorization/authorize-http-requests.adoc#match-by-mvc[the servlet path needs to be specified].
111+
For URIs that match an extension, like `.jsp`, use `regexMatcher("\\.jsp$")`.
112+
113+
Alternatively, you can change each of your `String` URI authorization rules to xref:servlet/authorization/authorize-http-requests.adoc#security-matchers[use a `RequestMatcher`].

Diff for: docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc

+36-21
Original file line numberDiff line numberDiff line change
@@ -577,15 +577,11 @@ http {
577577
======
578578

579579
[[match-by-mvc]]
580-
=== Using an MvcRequestMatcher
580+
=== Matching by Servlet Path
581581

582582
Generally speaking, you can use `requestMatchers(String)` as demonstrated above.
583583

584-
However, if you map Spring MVC to a different servlet path, then you need to account for that in your security configuration.
585-
586-
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.
587-
588-
You need to use `MvcRequestMatcher` to split the servlet path and the controller path in your configuration like so:
584+
However, if you have authorization rules from multiple servlets, you need to specify those:
589585

590586
.Match by MvcRequestMatcher
591587
[tabs]
@@ -594,16 +590,14 @@ Java::
594590
+
595591
[source,java,role="primary"]
596592
----
597-
@Bean
598-
MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
599-
return new MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc");
600-
}
593+
import static org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.servletPath;
601594
602595
@Bean
603-
SecurityFilterChain appEndpoints(HttpSecurity http, MvcRequestMatcher.Builder mvc) {
596+
SecurityFilterChain appEndpoints(HttpSecurity http) {
604597
http
605598
.authorizeHttpRequests((authorize) -> authorize
606-
.requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller")
599+
.requestMatchers(servletPath("/spring-mvc").pattern("/admin/**")).hasAuthority("admin")
600+
.requestMatchers(servletPath("/spring-mvc").pattern("/my/controller/**")).hasAuthority("controller")
607601
.anyRequest().authenticated()
608602
);
609603
@@ -616,34 +610,55 @@ Kotlin::
616610
[source,kotlin,role="secondary"]
617611
----
618612
@Bean
619-
fun mvc(introspector: HandlerMappingIntrospector): MvcRequestMatcher.Builder =
620-
MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc");
621-
622-
@Bean
623-
fun appEndpoints(http: HttpSecurity, mvc: MvcRequestMatcher.Builder): SecurityFilterChain =
613+
fun appEndpoints(http: HttpSecurity): SecurityFilterChain {
624614
http {
625615
authorizeHttpRequests {
626-
authorize(mvc.pattern("/my/controller/**"), hasAuthority("controller"))
616+
authorize("/spring-mvc", "/admin/**", hasAuthority("admin"))
617+
authorize("/spring-mvc", "/my/controller/**", hasAuthority("controller"))
627618
authorize(anyRequest, authenticated)
628619
}
629620
}
621+
}
630622
----
631623
632624
Xml::
633625
+
634626
[source,xml,role="secondary"]
635627
----
636628
<http>
629+
<intercept-url servlet-path="/spring-mvc" pattern="/admin/**" access="hasAuthority('admin')"/>
637630
<intercept-url servlet-path="/spring-mvc" pattern="/my/controller/**" access="hasAuthority('controller')"/>
638631
<intercept-url pattern="/**" access="authenticated"/>
639632
</http>
640633
----
641634
======
642635

643-
This need can arise in at least two different ways:
636+
This is because Spring Security requires all URIs to be absolute (minus the context path).
637+
638+
With Java, note that the `ServletRequestMatcherBuilders` return value can be reused, reducing repeated boilerplate:
639+
640+
[source,java,role="primary"]
641+
----
642+
import static org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.servletPath;
643+
644+
@Bean
645+
SecurityFilterChain appEndpoints(HttpSecurity http) {
646+
RequestMatcherBuilder mvc = servletPath("/spring-mvc");
647+
http
648+
.authorizeHttpRequests((authorize) -> authorize
649+
.requestMatchers(mvc.pattern("/admin/**")).hasAuthority("admin")
650+
.requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller")
651+
.anyRequest().authenticated()
652+
);
653+
654+
return http.build();
655+
}
656+
----
644657

645-
* If you use the `spring.mvc.servlet.path` Boot property to change the default path (`/`) to something else
646-
* If you register more than one Spring MVC `DispatcherServlet` (thus requiring that one of them not be the default path)
658+
[TIP]
659+
=====
660+
There are several other components that create request matchers for you like `PathRequest#toStaticResources#atCommonLocations`
661+
=====
647662

648663
[[match-by-custom]]
649664
=== Using a Custom Matcher
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.servlet.util.matcher;
18+
19+
import java.nio.charset.StandardCharsets;
20+
import java.util.Collection;
21+
import java.util.LinkedHashMap;
22+
import java.util.Map;
23+
import java.util.Objects;
24+
25+
import jakarta.servlet.RequestDispatcher;
26+
import jakarta.servlet.ServletContext;
27+
import jakarta.servlet.ServletRegistration;
28+
import jakarta.servlet.http.HttpServletMapping;
29+
import jakarta.servlet.http.HttpServletRequest;
30+
import jakarta.servlet.http.MappingMatch;
31+
32+
import org.springframework.lang.Nullable;
33+
import org.springframework.security.web.util.matcher.AndRequestMatcher;
34+
import org.springframework.security.web.util.matcher.RequestMatcher;
35+
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
36+
import org.springframework.util.Assert;
37+
import org.springframework.util.ObjectUtils;
38+
import org.springframework.web.util.UriUtils;
39+
import org.springframework.web.util.WebUtils;
40+
41+
/**
42+
* A {@link RequestMatcherBuilder} for specifying the servlet path separately from the
43+
* rest of the URI. This is helpful when you have more than one servlet.
44+
*
45+
* <p>
46+
* For example, if Spring MVC is deployed to `/mvc` and another servlet to `/other`, then
47+
* you can do
48+
* </p>
49+
*
50+
* <code>
51+
* http
52+
* .authorizeHttpRequests((authorize) -> authorize
53+
* .requestMatchers(servletPath("/mvc").pattern("/my/**", "/controller/**", "/endpoints/**")).hasAuthority(...
54+
* .requestMatchers(servletPath("/other").pattern("/my/**", "/non-mvc/**", "/endpoints/**")).hasAuthority(...
55+
* }
56+
* ...
57+
* </code>
58+
*
59+
* @author Josh Cummings
60+
* @since 6.5
61+
*/
62+
public final class ServletRequestMatcherBuilders {
63+
64+
private ServletRequestMatcherBuilders() {
65+
}
66+
67+
/**
68+
* Create {@link RequestMatcher}s whose URIs are relative to the context path, if any.
69+
* <p>
70+
* When there is no context path, then these URIs are effectively absolute.
71+
* @return a {@link RequestMatcherBuilder} that treats URIs as relative to the context
72+
* path, if any
73+
*/
74+
public static RequestMatcherBuilder requestPath() {
75+
return PathPatternRequestMatcher::pathPattern;
76+
}
77+
78+
/**
79+
* Create {@link RequestMatcher}s whose URIs are relative to the given
80+
* {@code servletPath}.
81+
*
82+
* <p>
83+
* The {@code servletPath} must correlate to a configured servlet in your application.
84+
* The path must be of the format {@code /path}.
85+
* @return a {@link RequestMatcherBuilder} that treats URIs as relative to the given
86+
* {@code servletPath}
87+
*/
88+
public static RequestMatcherBuilder servletPath(String servletPath) {
89+
Assert.notNull(servletPath, "servletPath cannot be null");
90+
Assert.isTrue(servletPath.startsWith("/"), "servletPath must start with '/'");
91+
Assert.isTrue(!servletPath.endsWith("/"), "servletPath must not end with a slash");
92+
Assert.isTrue(!servletPath.contains("*"), "servletPath must not contain a star");
93+
RequestMatcher servletPathMatcher = new ServletPathRequestMatcher(servletPath);
94+
return (method, pattern) -> {
95+
Assert.notNull(pattern, "pattern cannot be null");
96+
Assert.isTrue(pattern.startsWith("/"), "pattern must start with '/'");
97+
PathPatternRequestMatcher pathPattern = PathPatternRequestMatcher.pathPattern(method,
98+
servletPath + pattern);
99+
return new AndRequestMatcher(servletPathMatcher, pathPattern);
100+
};
101+
}
102+
103+
private record ServletPathRequestMatcher(String path) implements RequestMatcher {
104+
105+
@Override
106+
public boolean matches(HttpServletRequest request) {
107+
Assert.isTrue(servletExists(request), () -> this.path + "/* does not exist in your servlet registration "
108+
+ registrationMappings(request));
109+
return Objects.equals(this.path, getServletPathPrefix(request));
110+
}
111+
112+
private boolean servletExists(HttpServletRequest request) {
113+
if (request.getAttribute("org.springframework.test.web.servlet.MockMvc.MVC_RESULT_ATTRIBUTE") != null) {
114+
return true;
115+
}
116+
ServletContext servletContext = request.getServletContext();
117+
for (ServletRegistration registration : servletContext.getServletRegistrations().values()) {
118+
if (registration.getMappings().contains(this.path + "/*")) {
119+
return true;
120+
}
121+
}
122+
return false;
123+
}
124+
125+
private Map<String, Collection<String>> registrationMappings(HttpServletRequest request) {
126+
Map<String, Collection<String>> map = new LinkedHashMap<>();
127+
ServletContext servletContext = request.getServletContext();
128+
for (ServletRegistration registration : servletContext.getServletRegistrations().values()) {
129+
map.put(registration.getName(), registration.getMappings());
130+
}
131+
return map;
132+
}
133+
134+
@Nullable
135+
private static String getServletPathPrefix(HttpServletRequest request) {
136+
HttpServletMapping mapping = (HttpServletMapping) request.getAttribute(RequestDispatcher.INCLUDE_MAPPING);
137+
mapping = (mapping != null) ? mapping : request.getHttpServletMapping();
138+
if (ObjectUtils.nullSafeEquals(mapping.getMappingMatch(), MappingMatch.PATH)) {
139+
String servletPath = (String) request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE);
140+
servletPath = (servletPath != null) ? servletPath : request.getServletPath();
141+
servletPath = servletPath.endsWith("/") ? servletPath.substring(0, servletPath.length() - 1)
142+
: servletPath;
143+
return UriUtils.encodePath(servletPath, StandardCharsets.UTF_8);
144+
}
145+
return null;
146+
}
147+
}
148+
149+
}

0 commit comments

Comments
 (0)