Skip to content

Commit 2f375dd

Browse files
committed
Add Servlet Path support to Java DSL
Closes gh-16430
1 parent 763a0ea commit 2f375dd

File tree

8 files changed

+678
-33
lines changed

8 files changed

+678
-33
lines changed

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

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import org.springframework.security.web.util.matcher.OrRequestMatcher;
5050
import org.springframework.security.web.util.matcher.RegexRequestMatcher;
5151
import org.springframework.security.web.util.matcher.RequestMatcher;
52+
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
5253
import org.springframework.util.Assert;
5354
import org.springframework.util.ClassUtils;
5455
import org.springframework.web.context.WebApplicationContext;
@@ -74,6 +75,8 @@ public abstract class AbstractRequestMatcherRegistry<C> {
7475

7576
private static final RequestMatcher ANY_REQUEST = AnyRequestMatcher.INSTANCE;
7677

78+
private final RequestMatcherBuilder requestMatcherBuilder = new DefaultRequestMatcherBuilder();
79+
7780
private ApplicationContext context;
7881

7982
private boolean anyRequestConfigured = false;
@@ -217,13 +220,9 @@ public C requestMatchers(HttpMethod method, String... patterns) {
217220
if (servletContext == null) {
218221
return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
219222
}
220-
List<RequestMatcher> matchers = new ArrayList<>();
221-
for (String pattern : patterns) {
222-
AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null);
223-
MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0);
224-
matchers.add(new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant));
225-
}
226-
return requestMatchers(matchers.toArray(new RequestMatcher[0]));
223+
RequestMatcherBuilder builder = context.getBeanProvider(RequestMatcherBuilder.class)
224+
.getIfUnique(() -> this.requestMatcherBuilder);
225+
return requestMatchers(builder.pattern(method, patterns));
227226
}
228227

229228
private boolean anyPathsDontStartWithLeadingSlash(String... patterns) {
@@ -264,11 +263,14 @@ private RequestMatcher resolve(AntPathRequestMatcher ant, MvcRequestMatcher mvc,
264263
}
265264

266265
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.";
266+
String template = """
267+
This method cannot decide whether these patterns are Spring MVC patterns or not. \
268+
This is because there is more than one mappable servlet in your servlet context: %s.
269+
270+
To address this, please create one ServletRequestMatcherBuilder#servletPath for each servlet that has \
271+
authorized endpoints and use them to construct request matchers manually. \
272+
If all your URIs are unambiguous, then you can simply publish one ServletRequestMatcherBuilders#servletPath as \
273+
a @Bean and Spring Security will use it for all URIs""";
272274
Map<String, Collection<String>> mappings = new LinkedHashMap<>();
273275
for (ServletRegistration registration : registrations) {
274276
mappings.put(registration.getClassName(), registration.getMappings());
@@ -402,6 +404,17 @@ static List<RequestMatcher> regexMatchers(String... regexPatterns) {
402404

403405
}
404406

407+
class DefaultRequestMatcherBuilder implements RequestMatcherBuilder {
408+
409+
@Override
410+
public RequestMatcher pattern(HttpMethod method, String pattern) {
411+
AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null);
412+
MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0);
413+
return new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant);
414+
}
415+
416+
}
417+
405418
static class DeferredRequestMatcher implements RequestMatcher {
406419

407420
final Function<ServletContext, RequestMatcher> requestMatcherFactory;

config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.junit.jupiter.api.BeforeEach;
2525
import org.junit.jupiter.api.Test;
2626

27+
import org.springframework.beans.BeansException;
2728
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
2829
import org.springframework.beans.factory.ObjectProvider;
2930
import org.springframework.context.ApplicationContext;
@@ -42,6 +43,7 @@
4243
import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher;
4344
import org.springframework.security.web.util.matcher.RegexRequestMatcher;
4445
import org.springframework.security.web.util.matcher.RequestMatcher;
46+
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
4547
import org.springframework.test.web.servlet.MockMvc;
4648
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
4749
import org.springframework.web.context.WebApplicationContext;
@@ -87,6 +89,13 @@ public void setUp() {
8789
given(given).willReturn(postProcessors);
8890
given(postProcessors.getObject()).willReturn(NO_OP_OBJECT_POST_PROCESSOR);
8991
given(this.context.getServletContext()).willReturn(MockServletContext.mvc());
92+
ObjectProvider<RequestMatcherBuilder> requestMatcherFactories = new ObjectProvider<>() {
93+
@Override
94+
public RequestMatcherBuilder getObject() throws BeansException {
95+
return AbstractRequestMatcherRegistryTests.this.matcherRegistry.new DefaultRequestMatcherBuilder();
96+
}
97+
};
98+
given(this.context.getBeanProvider(RequestMatcherBuilder.class)).willReturn(requestMatcherFactories);
9099
this.matcherRegistry.setApplicationContext(this.context);
91100
mockMvcIntrospector(true);
92101
}

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

Lines changed: 40 additions & 0 deletions
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.PathPatternRequestMatcher;
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,24 @@ SecurityObservationSettings observabilityDefaults() {
13171337

13181338
}
13191339

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

docs/modules/ROOT/pages/migration-7/web.adoc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,23 @@ Xml::
102102
</b:bean>
103103
----
104104
======
105+
106+
== Favor PathPatternRequestMatcher
107+
108+
`MvcRequestMatcher` is deprecated in 6.5.
109+
XML, Kotlin, and Java will all favor `PathPatternRequestMatcher` by default in 7.0.
110+
111+
If you aren't already publishing a `RequestMatcherBuilder` bean, you can prepare for this change in defaults by publishing the following bean:
112+
113+
[source,java]
114+
----
115+
@Bean
116+
RequestMatcherBuilder favorPathPattern() {
117+
return ServletRequestMatcherBuilders.deducePath();
118+
}
119+
----
120+
121+
This static factory aligns with the Spring Security defaults for request matchers except that it uses `PathPatternRequestMatcher` instead.
122+
It reflects what the default will be in Spring Security 7.
123+
124+
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`].

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

Lines changed: 88 additions & 21 deletions
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,107 @@ 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+
The primary reason for this is that Spring MVC URIs are relative to the servlet.
637+
In other words, an authorization rule usually doesn't include the servlet path.
638+
639+
Other URIs may include the servlet path.
640+
Because of that, the best practice is to always supply the servlet path when your application has more than one servlet.
641+
642+
==== But I do only have one servlet, why is Spring Security complaining?
643+
644+
Sometimes, application containers include additional servlets.
645+
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)
646+
647+
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.
648+
649+
For example, the above Java sample can be rewritten as:
650+
651+
[tabs]
652+
======
653+
Java::
654+
+
655+
[source,java,role="primary"]
656+
----
657+
@Bean
658+
RequestMatcherBuilder mvc() {
659+
return ServeltRequestMatcherBuilders.servletPath("/spring-mvc");
660+
}
661+
662+
@Bean
663+
SecurityFilterChain security(HttpSecurity http) throws Exception {
664+
http
665+
.authorizeHttpRequests((authorize) -> authorize
666+
.requestMatchers("/admin/**").hasAuthority("admin")
667+
.requestMatchers("/my/controller/**").hasAuthority("controller")
668+
.anyRequest().authenticated()
669+
);
670+
return http.build();
671+
}
672+
----
673+
======
674+
675+
[TIP]
676+
====
677+
If you are a Spring Boot application, you may be able to publish the above bean like so:
678+
679+
[source,java]
680+
----
681+
@Bean
682+
RequestMatcherBuilder mvc(WebMvcProperties properties) {
683+
return ServletRequestMatcherBuilders.servletPath(proeprties.getServlet().getPath());
684+
}
685+
----
686+
====
687+
688+
This same strategy is useful when it comes to static resources.
689+
You can permit these by using Spring Boot's `RequestMatchers` static factory like so:
690+
691+
[tabs]
692+
======
693+
Java::
694+
+
695+
[source,java]
696+
----
697+
@Bean
698+
RequestMatcherBuilder mvc() {
699+
return ServletRequestMatcherBuilders.servletPath("/mvc");
700+
}
701+
702+
@Bean
703+
SecurityFilterChain security(HttpSecurity http) throws Exception {
704+
http
705+
.authorizeHttpRequests((authorize) -> authorize
706+
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
707+
.requestMatchers("/my/**", "/app/**", "/requests/**").hasAuthority("app")
708+
)
709+
}
710+
----
711+
======
644712

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)
713+
Since `atCommonLocations` returns instances of `RequestMatcher`, this technique allows you to have all your `String`-based authorizations relative to the globally-configured `ServletRequestMatcherBuilders#servletPath`.
647714

648715
[[match-by-custom]]
649716
=== Using a Custom Matcher

0 commit comments

Comments
 (0)