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 24, 2025
1 parent 054ce37 commit f8ca5ce
Show file tree
Hide file tree
Showing 6 changed files with 259 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 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
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,21 @@ SecurityObservationSettings observabilityDefaults() {

}

@Configuration
@EnableWebSecurity
@EnableWebMvc
static class MvcRequestMatcherBuilderConfig {

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

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,102 @@
/*
* 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 jakarta.servlet.http.HttpServletRequest;

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;

/**
* 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 static final ServletPathRequestMatcher DEFAULT_SERVLET = new ServletPathRequestMatcher("");

private ServletRequestMatcherBuilders() {
}

/**
* Create {@link RequestMatcher}s that will only match URIs against the default
* servlet.
* @return a {@link ServletRequestMatcherBuilders} that matches URIs mapped to the
* default servlet
*/
public static RequestMatcherBuilder defaultServlet() {
return servletPathInternal(DEFAULT_SERVLET, "");
}

/**
* Create {@link RequestMatcher}s that will only match URIs against the given servlet
* path
*
* <p>
* The path must be of the format {@code /path}. It should not end in `/` or `/*`, nor
* should it be a file extension. To specify the default servlet, use
* {@link #defaultServlet()}.
* </p>
* @return a {@link ServletRequestMatcherBuilders} that matches URIs mapped to the
* given servlet path
*/
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");
return servletPathInternal(new ServletPathRequestMatcher(servletPath), servletPath);
}

private static RequestMatcherBuilder servletPathInternal(RequestMatcher servletPathMatcher, String 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) {
return this.path.equals(request.getServletPath());
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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 org.junit.jupiter.api.Test;

import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;

/**
* Tests for {@link ServletRequestMatcherBuilders}
*/
class ServletRequestMatcherBuildersTests {

@Test
void patternWhenServletPathThenMatchesOnlyServletPath() {
RequestMatcherBuilder builder = ServletRequestMatcherBuilders.servletPath("/servlet/path");
RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint");
assertThat(requestMatcher.matches(request("/servlet/path/endpoint", "/servlet/path"))).isTrue();
assertThat(requestMatcher.matches(request("/endpoint", ""))).isFalse();
}

@Test
void patternWhenDefaultServletThenMatchesOnlyDefaultServlet() {
RequestMatcherBuilder builder = ServletRequestMatcherBuilders.defaultServlet();
RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint");
assertThat(requestMatcher.matches(request("/servlet/path/endpoint", "/servlet/path"))).isFalse();
assertThat(requestMatcher.matches(request("/endpoint", ""))).isTrue();
}

@Test
void servletPathWhenEndsWithSlashOrStarThenIllegalArgument() {
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> ServletRequestMatcherBuilders.servletPath("/path/**"));
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> ServletRequestMatcherBuilders.servletPath("/path/*"));
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> ServletRequestMatcherBuilders.servletPath("/path/"));
}

MockHttpServletRequest request(String uri, String servletPath) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", uri);
request.setServletPath(servletPath);
return request;
}

}

0 comments on commit f8ca5ce

Please sign in to comment.