Skip to content

Commit

Permalink
Add Servlet Path support to Java DSL
Browse files Browse the repository at this point in the history
  • Loading branch information
jzheaux committed Jan 16, 2025
1 parent 763a0ea commit ae29d07
Show file tree
Hide file tree
Showing 8 changed files with 679 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -74,6 +75,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 @@ -217,13 +220,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.pattern(method, patterns));
}

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

private static String computeErrorMessage(Collection<? extends ServletRegistration> 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<String, Collection<String>> mappings = new LinkedHashMap<>();
for (ServletRegistration registration : registrations) {
mappings.put(registration.getClassName(), registration.getMappings());
Expand Down Expand Up @@ -402,6 +404,17 @@ static List<RequestMatcher> 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<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> 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);
}
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.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;
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,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();
}

}

}
20 changes: 20 additions & 0 deletions docs/modules/ROOT/pages/migration-7/web.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,23 @@ Xml::
</b:bean>
----
======

== 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`].
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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()
);
Expand All @@ -616,34 +610,107 @@ 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::
+
[source,xml,role="secondary"]
----
<http>
<intercept-url servlet-path="/spring-mvc" pattern="/admin/**" access="hasAuthority('admin')"/>
<intercept-url servlet-path="/spring-mvc" pattern="/my/controller/**" access="hasAuthority('controller')"/>
<intercept-url pattern="/**" access="authenticated"/>
</http>
----
======

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
Expand Down
Loading

0 comments on commit ae29d07

Please sign in to comment.