Skip to content

Commit

Permalink
A RequestMatcherBuilder API
Browse files Browse the repository at this point in the history
  • Loading branch information
jzheaux committed Dec 2, 2024
1 parent 2b5a2ee commit 313c94d
Show file tree
Hide file tree
Showing 9 changed files with 746 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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;
Expand All @@ -73,6 +74,8 @@ public abstract class AbstractRequestMatcherRegistry<C> {

private static final RequestMatcher ANY_REQUEST = AnyRequestMatcher.INSTANCE;

private final RequestMatcherBuilder requestMatcherBuilder = new DefaultRequestMatcherBuilder();

private ApplicationContext context;

private boolean anyRequestConfigured = false;
Expand Down Expand Up @@ -216,13 +219,9 @@ public C requestMatchers(HttpMethod method, String... patterns) {
if (servletContext == null) {
return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
}
List<RequestMatcher> 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.requestMatchers(method, patterns));
}

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

}

class DefaultRequestMatcherBuilder implements RequestMatcherBuilder {

@Override
public RequestMatcher requestMatcher(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<ServletContext, RequestMatcher> requestMatcherFactory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<RequestMatcherBuilder> requestMatcherBuilders = new ObjectProvider<>() {
@Override
public RequestMatcherBuilder getObject() throws BeansException {
return AbstractRequestMatcherRegistryTests.this.matcherRegistry.new DefaultRequestMatcherBuilder();
}
};
given(this.context.getBeanProvider(RequestMatcherBuilder.class)).willReturn(requestMatcherBuilders);
this.matcherRegistry.setApplicationContext(this.context);
mockMvcIntrospector(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.MvcRequestMatcherBuilder;
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;
Expand All @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1262,6 +1278,10 @@ void rootGet() {
void rootPost() {
}

@GetMapping("/path")
void path() {
}

}

@Configuration
Expand Down Expand Up @@ -1317,4 +1337,23 @@ SecurityObservationSettings observabilityDefaults() {

}

@Configuration
@EnableWebSecurity
@EnableWebMvc
static class MvcRequestMatcherBuilderConfig {

@Bean
RequestMatcherBuilder servletPath(HandlerMappingIntrospector introspector) {
return new MvcRequestMatcherBuilder(introspector, "/mvc");
}

@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/path").hasRole("USER"))
.httpBasic(withDefaults());
return http.build();
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -640,11 +640,121 @@ Xml::
----
======

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

* 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)

=== Using a `RequestMatcherBuilder`

You can reduce the boilerplate of constructing several `MvcRequestMatcher` instances by providing a single instance of `RequestMatcherBuilder`.

For example, if all of your requests in `requestMatcher(String)` are MVC requests, then you can do:

[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
RequestMatcherBuilder allRequestsAreMvc(HandlerMappingIntrospector introspector) {
MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector).servletPath("/my-servlet-path");
return mvc::pattern;
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean fun allRequestsAreMvc(introspector: HandlerMappingIntrospector?): RequestMatcherBuilder {
var mvc = MvcRequestMatcher.Builder(introspector).servletPath("/my-servlet-path")
return mvc::pattern
}
----
======

Spring Security will use this builder for all request matchers specified as a `String`.

[TIP]
====
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`.
====

You can permit these by using Spring Boot's `RequestMatchers` static factory like so:

[tabs]
======
Java::
+
[source,java]
----
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/my", "/mvc", "/requests").hasAuthority("app")
)
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
val printview: RequestMatcher = { (request) -> request.getParameter("print") != null }
http {
authorizeHttpRequests {
authorize(PathRequest.toStaticResources().atCommonLocations(), permitAll)
authorize("/my", hasAuthority("app"))
authorize("/mvc", hasAuthority("app"))
authorize("/requests", hasAuthority("app"))
}
}
----
======

Since `atCommonLocations` returns instances of `RequestMatcher`, this technique allows you to publish an MVC-based `RequestMatcherBuilder` for the rest.

In the event that <<conditions-for-servlet-path-matching, the absolute path would be ambiguous>>, you can publish an `MvcDelegatingRequestMatcherBuilder` instance instead:

[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
RequestMatcherBuilder allRequestsAreMvc(HandlerMappingIntrospector introspector) {
return MvcDelegatingRequestMatcherBuilder(introspector, "/my-mvc-servlet-path");
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun allRequestsAreMvc(introspector: HandlerMappingIntrospector?): RequestMatcherBuilder {
return MvcDelegatingRequestMatcherBuilder(introspector, "/my-mvc-servlet-path");
}
----
======

This produces matchers that check first if the request is an MVC request; if it is, use an `MvcRequestMatcher` and otherwise use an `AntPathRequestMatcher`.

[NOTE]
====
The reason this `RequestMatcherBuilder` is not used by default is because of potential ambiguities in the meaning of given `String` patterns.
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.
In this case, it's unclear whether by `requestMatchers("/example")` you mean to secure `/example`` or `/mvc-servlet-path/example`.
Publishing any `RequestMatcherBuilder` indicates that you will handle these ambiguous situations, should they arise.
====

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
*/
public class MvcRequestMatcher implements RequestMatcher, RequestVariablesExtractor {

private final DefaultMatcher defaultMatcher = new DefaultMatcher();
private RequestMatcher defaultMatcher = new DefaultMatcher();

private final HandlerMappingIntrospector introspector;

Expand Down Expand Up @@ -130,6 +130,16 @@ protected final String getServletPath() {
return this.servletPath;
}

/**
* The matcher that this should fall back on in the event that the request isn't
* recognized by Spring MVC
* @param defaultMatcher the default matcher to use
* @since 6.4
*/
public void setDefaultMatcher(RequestMatcher defaultMatcher) {
this.defaultMatcher = defaultMatcher;
}

@Override
public boolean equals(Object o) {
if (this == o) {
Expand Down
Loading

0 comments on commit 313c94d

Please sign in to comment.