Skip to content

Commit 313c94d

Browse files
committed
A RequestMatcherBuilder API
Closes spring-projectsgh-13562
1 parent 2b5a2ee commit 313c94d

File tree

9 files changed

+746
-8
lines changed

9 files changed

+746
-8
lines changed

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import org.springframework.security.web.util.matcher.OrRequestMatcher;
4949
import org.springframework.security.web.util.matcher.RegexRequestMatcher;
5050
import org.springframework.security.web.util.matcher.RequestMatcher;
51+
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
5152
import org.springframework.util.Assert;
5253
import org.springframework.util.ClassUtils;
5354
import org.springframework.web.context.WebApplicationContext;
@@ -73,6 +74,8 @@ public abstract class AbstractRequestMatcherRegistry<C> {
7374

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

77+
private final RequestMatcherBuilder requestMatcherBuilder = new DefaultRequestMatcherBuilder();
78+
7679
private ApplicationContext context;
7780

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

228227
private boolean anyPathsDontStartWithLeadingSlash(String... patterns) {
@@ -473,6 +472,17 @@ static List<RequestMatcher> regexMatchers(String... regexPatterns) {
473472

474473
}
475474

475+
class DefaultRequestMatcherBuilder implements RequestMatcherBuilder {
476+
477+
@Override
478+
public RequestMatcher requestMatcher(HttpMethod method, String pattern) {
479+
AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null);
480+
MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0);
481+
return new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant);
482+
}
483+
484+
}
485+
476486
static class DeferredRequestMatcher implements RequestMatcher {
477487

478488
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> requestMatcherBuilders = 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(requestMatcherBuilders);
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: 39 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.MvcRequestMatcherBuilder;
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,23 @@ SecurityObservationSettings observabilityDefaults() {
13171337

13181338
}
13191339

1340+
@Configuration
1341+
@EnableWebSecurity
1342+
@EnableWebMvc
1343+
static class MvcRequestMatcherBuilderConfig {
1344+
1345+
@Bean
1346+
RequestMatcherBuilder servletPath(HandlerMappingIntrospector introspector) {
1347+
return new MvcRequestMatcherBuilder(introspector, "/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+
return http.build();
1355+
}
1356+
1357+
}
1358+
13201359
}

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,11 +640,121 @@ Xml::
640640
----
641641
======
642642

643+
[[conditions-for-servlet-path-matching]]
643644
This need can arise in at least two different ways:
644645

645646
* If you use the `spring.mvc.servlet.path` Boot property to change the default path (`/`) to something else
646647
* If you register more than one Spring MVC `DispatcherServlet` (thus requiring that one of them not be the default path)
647648

649+
=== Using a `RequestMatcherBuilder`
650+
651+
You can reduce the boilerplate of constructing several `MvcRequestMatcher` instances by providing a single instance of `RequestMatcherBuilder`.
652+
653+
For example, if all of your requests in `requestMatcher(String)` are MVC requests, then you can do:
654+
655+
[tabs]
656+
======
657+
Java::
658+
+
659+
[source,java,role="primary"]
660+
----
661+
@Bean
662+
RequestMatcherBuilder allRequestsAreMvc(HandlerMappingIntrospector introspector) {
663+
MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector).servletPath("/my-servlet-path");
664+
return mvc::pattern;
665+
}
666+
----
667+
668+
Kotlin::
669+
+
670+
[source,kotlin,role="secondary"]
671+
----
672+
@Bean fun allRequestsAreMvc(introspector: HandlerMappingIntrospector?): RequestMatcherBuilder {
673+
var mvc = MvcRequestMatcher.Builder(introspector).servletPath("/my-servlet-path")
674+
return mvc::pattern
675+
}
676+
----
677+
======
678+
679+
Spring Security will use this builder for all request matchers specified as a `String`.
680+
681+
[TIP]
682+
====
683+
Often the only non-MVC requests that there are in a Spring Boot application are those to static resources like `/css", '/js', and 'favicon.ico`.
684+
====
685+
686+
You can permit these by using Spring Boot's `RequestMatchers` static factory like so:
687+
688+
[tabs]
689+
======
690+
Java::
691+
+
692+
[source,java]
693+
----
694+
@Bean
695+
SecurityFilterChain security(HttpSecurity http) throws Exception {
696+
http
697+
.authorizeHttpRequests((authorize) -> authorize
698+
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
699+
.requestMatchers("/my", "/mvc", "/requests").hasAuthority("app")
700+
)
701+
}
702+
----
703+
704+
Kotlin::
705+
+
706+
[source,kotlin,role="secondary"]
707+
----
708+
val printview: RequestMatcher = { (request) -> request.getParameter("print") != null }
709+
http {
710+
authorizeHttpRequests {
711+
authorize(PathRequest.toStaticResources().atCommonLocations(), permitAll)
712+
authorize("/my", hasAuthority("app"))
713+
authorize("/mvc", hasAuthority("app"))
714+
authorize("/requests", hasAuthority("app"))
715+
}
716+
}
717+
----
718+
======
719+
720+
Since `atCommonLocations` returns instances of `RequestMatcher`, this technique allows you to publish an MVC-based `RequestMatcherBuilder` for the rest.
721+
722+
In the event that <<conditions-for-servlet-path-matching, the absolute path would be ambiguous>>, you can publish an `MvcDelegatingRequestMatcherBuilder` instance instead:
723+
724+
[tabs]
725+
======
726+
Java::
727+
+
728+
[source,java,role="primary"]
729+
----
730+
@Bean
731+
RequestMatcherBuilder allRequestsAreMvc(HandlerMappingIntrospector introspector) {
732+
return MvcDelegatingRequestMatcherBuilder(introspector, "/my-mvc-servlet-path");
733+
}
734+
----
735+
736+
Kotlin::
737+
+
738+
[source,kotlin,role="secondary"]
739+
----
740+
@Bean
741+
fun allRequestsAreMvc(introspector: HandlerMappingIntrospector?): RequestMatcherBuilder {
742+
return MvcDelegatingRequestMatcherBuilder(introspector, "/my-mvc-servlet-path");
743+
}
744+
----
745+
======
746+
747+
This produces matchers that check first if the request is an MVC request; if it is, use an `MvcRequestMatcher` and otherwise use an `AntPathRequestMatcher`.
748+
749+
[NOTE]
750+
====
751+
The reason this `RequestMatcherBuilder` is not used by default is because of potential ambiguities in the meaning of given `String` patterns.
752+
For example, consider a servlet mapped to `/example` and a Spring MVC endpoint mapped to `/mvc-servlet-path/example` where `/mvc-servlet-path` is the servlet path for MVC endpoints.
753+
In this case, it's unclear whether by `requestMatchers("/example")` you mean to secure `/example`` or `/mvc-servlet-path/example`.
754+
755+
Publishing any `RequestMatcherBuilder` indicates that you will handle these ambiguous situations, should they arise.
756+
====
757+
648758
[[match-by-custom]]
649759
=== Using a Custom Matcher
650760

web/src/main/java/org/springframework/security/web/servlet/util/matcher/MvcRequestMatcher.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
*/
5050
public class MvcRequestMatcher implements RequestMatcher, RequestVariablesExtractor {
5151

52-
private final DefaultMatcher defaultMatcher = new DefaultMatcher();
52+
private RequestMatcher defaultMatcher = new DefaultMatcher();
5353

5454
private final HandlerMappingIntrospector introspector;
5555

@@ -130,6 +130,16 @@ protected final String getServletPath() {
130130
return this.servletPath;
131131
}
132132

133+
/**
134+
* The matcher that this should fall back on in the event that the request isn't
135+
* recognized by Spring MVC
136+
* @param defaultMatcher the default matcher to use
137+
* @since 6.4
138+
*/
139+
public void setDefaultMatcher(RequestMatcher defaultMatcher) {
140+
this.defaultMatcher = defaultMatcher;
141+
}
142+
133143
@Override
134144
public boolean equals(Object o) {
135145
if (this == o) {

0 commit comments

Comments
 (0)