Skip to content

Commit

Permalink
Add ServletRequestMatcherBuilders
Browse files Browse the repository at this point in the history
This static factory simplifes the creation of RequestMatchers
that specify the servlet path.

Closes spring-projectsgh-16430
  • Loading branch information
jzheaux committed Jan 29, 2025
1 parent 6ab0e75 commit bdd1cf1
Show file tree
Hide file tree
Showing 6 changed files with 333 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -264,11 +264,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 reuse one ServletRequestMatcherBuilders#servletPath \
for all URIs""";
Map<String, Collection<String>> mappings = new LinkedHashMap<>();
for (ServletRegistration registration : registrations) {
mappings.put(registration.getClassName(), registration.getMappings());
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.ServletRequestMatcherBuilders;
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,25 @@ SecurityObservationSettings observabilityDefaults() {

}

@Configuration
@EnableWebSecurity
@EnableWebMvc
static class MvcRequestMatcherBuilderConfig {

@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
RequestMatcherBuilder mvc = ServletRequestMatcherBuilders.servletPath("/mvc");
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(mvc.pattern("/path/**")).hasRole("USER")
)
.httpBasic(withDefaults());
// @formatter:on

return http.build();
}

}

}
9 changes: 9 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,12 @@ Xml::
</b:bean>
----
======

== Use Absolute Authorization URIs

The Java DSL now requires that all URIs be absolute (less any context root).

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].
For URIs that match an extension, like `.jsp`, use `regexMatcher("\\.jsp$")`.

Alternatively, you can 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,55 @@ 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:
This is because Spring Security requires all URIs to be absolute (minus the context path).

With Java, note that the `ServletRequestMatcherBuilders` return value can be reused, reducing repeated boilerplate:

[source,java,role="primary"]
----
import static org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.servletPath;
@Bean
SecurityFilterChain appEndpoints(HttpSecurity http) {
RequestMatcherBuilder mvc = servletPath("/spring-mvc");
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(mvc.pattern("/admin/**")).hasAuthority("admin")
.requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller")
.anyRequest().authenticated()
);
return http.build();
}
----

* 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)
[TIP]
=====
There are several other components that create request matchers for you like `PathRequest#toStaticResources#atCommonLocations`
=====

[[match-by-custom]]
=== Using a Custom Matcher
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.security.web.servlet.util.matcher;

import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletRegistration;
import jakarta.servlet.http.HttpServletMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.MappingMatch;

import org.springframework.lang.Nullable;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
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.ObjectUtils;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.WebUtils;

/**
* A {@link RequestMatcherBuilder} for specifying the servlet path separately from the
* rest of the URI. This is helpful when you have more than one servlet.
*
* <p>
* For example, if Spring MVC is deployed to `/mvc` and another servlet to `/other`, then
* you can do
* </p>
*
* <code>
* http
* .authorizeHttpRequests((authorize) -> authorize
* .requestMatchers(servletPath("/mvc").pattern("/my/**", "/controller/**", "/endpoints/**")).hasAuthority(...
* .requestMatchers(servletPath("/other").pattern("/my/**", "/non-mvc/**", "/endpoints/**")).hasAuthority(...
* }
* ...
* </code>
*
* @author Josh Cummings
* @since 6.5
*/
public final class ServletRequestMatcherBuilders {

private ServletRequestMatcherBuilders() {
}

/**
* Create {@link RequestMatcher}s whose URIs are relative to the context path, if any.
* <p>
* When there is no context path, then these URIs are effectively absolute.
* @return a {@link RequestMatcherBuilder} that treats URIs as relative to the context
* path, if any
*/
public static RequestMatcherBuilder builder() {
return PathPatternRequestMatcher::pathPattern;
}

/**
* Create {@link RequestMatcher}s whose URIs are relative to the given
* {@code servletPath}.
*
* <p>
* The {@code servletPath} must correlate to a configured servlet in your application.
* The path must be of the format {@code /path}.
* @return a {@link RequestMatcherBuilder} that treats URIs as relative to the given
* {@code servletPath}
*/
public static RequestMatcherBuilder servletPath(String servletPath) {
Assert.notNull(servletPath, "servletPath cannot be null");
Assert.isTrue(servletPath.startsWith("/"), "servletPath must start with '/'");
Assert.isTrue(!servletPath.endsWith("/"), "servletPath must not end with a slash");
Assert.isTrue(!servletPath.contains("*"), "servletPath must not contain a star");
RequestMatcher servletPathMatcher = new ServletPathRequestMatcher(servletPath);
return (method, pattern) -> {
Assert.notNull(pattern, "pattern cannot be null");
Assert.isTrue(pattern.startsWith("/"), "pattern must start with '/'");
PathPatternRequestMatcher pathPattern = PathPatternRequestMatcher.pathPattern(method,
servletPath + pattern);
return new AndRequestMatcher(servletPathMatcher, pathPattern);
};
}

private record ServletPathRequestMatcher(String path) implements RequestMatcher {

@Override
public boolean matches(HttpServletRequest request) {
Assert.isTrue(servletExists(request), () -> this.path + "/* does not exist in your servlet registration "
+ registrationMappings(request));
return Objects.equals(this.path, getServletPathPrefix(request));
}

private boolean servletExists(HttpServletRequest request) {
if (request.getAttribute("org.springframework.test.web.servlet.MockMvc.MVC_RESULT_ATTRIBUTE") != null) {
return true;
}
ServletContext servletContext = request.getServletContext();
for (ServletRegistration registration : servletContext.getServletRegistrations().values()) {
if (registration.getMappings().contains(this.path + "/*")) {
return true;
}
}
return false;
}

private Map<String, Collection<String>> registrationMappings(HttpServletRequest request) {
Map<String, Collection<String>> map = new LinkedHashMap<>();
ServletContext servletContext = request.getServletContext();
for (ServletRegistration registration : servletContext.getServletRegistrations().values()) {
map.put(registration.getName(), registration.getMappings());
}
return map;
}

@Nullable
private static String getServletPathPrefix(HttpServletRequest request) {
HttpServletMapping mapping = (HttpServletMapping) request.getAttribute(RequestDispatcher.INCLUDE_MAPPING);
mapping = (mapping != null) ? mapping : request.getHttpServletMapping();
if (ObjectUtils.nullSafeEquals(mapping.getMappingMatch(), MappingMatch.PATH)) {
String servletPath = (String) request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE);
servletPath = (servletPath != null) ? servletPath : request.getServletPath();
servletPath = servletPath.endsWith("/") ? servletPath.substring(0, servletPath.length() - 1)
: servletPath;
return UriUtils.encodePath(servletPath, StandardCharsets.UTF_8);
}
return null;
}
}

}
Loading

0 comments on commit bdd1cf1

Please sign in to comment.