From 4b6fed0667a88a4cfb4a94a451f6c6285e10b1c6 Mon Sep 17 00:00:00 2001 From: Marcus Da Coregio Date: Thu, 6 Oct 2022 09:53:54 -0300 Subject: [PATCH] Add static factory method to AntPathRequestMather and RegexRequestMatcher Closes gh-11938 --- .../authorize-http-requests.adoc | 131 ++++++++++++++++++ etc/checkstyle/checkstyle.xml | 2 + .../util/matcher/AntPathRequestMatcher.java | 37 +++++ .../web/util/matcher/RegexRequestMatcher.java | 33 +++++ .../matcher/AntPathRequestMatcherTests.java | 46 ++++++ .../matcher/RegexRequestMatcherTests.java | 43 ++++++ 6 files changed, 292 insertions(+) diff --git a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc index f6908d633a..a0c6a444f9 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc @@ -281,3 +281,134 @@ open fun web(http: HttpSecurity): SecurityFilterChain { } ---- ==== + +== Request Matchers + +The `RequestMatcher` interface is used to determine if a request matches a given rule. +We use `securityMatchers` to determine if a given `HttpSecurity` should be applied to a given request. +The same way, we can use `requestMatchers` to determine the authorization rules that we should apply to a given request. +Look at the following example: + +==== +.Java +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/**") <1> + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/user/**").hasRole("USER") <2> + .requestMatchers("/admin/**").hasRole("ADMIN") <3> + .anyRequest().authenticated() <4> + ) + .formLogin(withDefaults()); + return http.build(); + } +} +---- +.Kotlin +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebSecurity +open class SecurityConfig { + + @Bean + open fun web(http: HttpSecurity): SecurityFilterChain { + http { + securityMatcher("/api/**") <1> + authorizeHttpRequests { + authorize("/user/**", hasRole("USER")) <2> + authorize("/admin/**", hasRole("ADMIN")) <3> + authorize(anyRequest, authenticated) <4> + } + } + return http.build() + } + +} +---- +==== + +<1> Configure `HttpSecurity` to only be applied to URLs that start with `/api/` +<2> Allow access to URLs that start with `/user/` to users with the `USER` role +<3> Allow access to URLs that start with `/admin/` to users with the `ADMIN` role +<4> Any other request that doesn't match the rules above, will require authentication + +The `securityMatcher(s)` and `requestMatcher(s)` methods will decide which `RequestMatcher` implementation fits best for your application: If Spring MVC is in the classpath, then `MvcRequestMatcher` will be used, otherwise, `AntPathRequestMatcher` will be used. +You can read more about the Spring MVC integration xref:servlet/integrations/mvc.adoc[here]. + +If you want to use a specific `RequestMatcher`, just pass an implementation to the `securityMatcher` and/or `requestMatcher` methods: + +==== +.Java +[source,java,role="primary"] +---- +import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; <1> +import static org.springframework.security.web.util.matcher.RegexRequestMatcher.regexMatcher; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher(antMatcher("/api/**")) <2> + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(antMatcher("/user/**")).hasRole("USER") <3> + .requestMatchers(regexMatcher("/admin/.*")).hasRole("ADMIN") <4> + .requestMatchers(new MyCustomRequestMatcher()).hasRole("SUPERVISOR") <5> + .anyRequest().authenticated() + ) + .formLogin(withDefaults()); + return http.build(); + } +} + +public class MyCustomRequestMatcher implements RequestMatcher { + + @Override + public boolean matches(HttpServletRequest request) { + // ... + } +} +---- +.Kotlin +[source,kotlin,role="secondary"] +---- +import org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher <1> +import org.springframework.security.web.util.matcher.RegexRequestMatcher.regexMatcher + +@Configuration +@EnableWebSecurity +open class SecurityConfig { + + @Bean + open fun web(http: HttpSecurity): SecurityFilterChain { + http { + securityMatcher(antMatcher("/api/**")) <2> + authorizeHttpRequests { + authorize(antMatcher("/user/**"), hasRole("USER")) <3> + authorize(regexMatcher("/admin/**"), hasRole("ADMIN")) <4> + authorize(MyCustomRequestMatcher(), hasRole("SUPERVISOR")) <5> + authorize(anyRequest, authenticated) + } + } + return http.build() + } + +} +---- +==== + +<1> Import the static factory methods from `AntPathRequestMatcher` and `RegexRequestMatcher` to create `RequestMatcher` instances. +<2> Configure `HttpSecurity` to only be applied to URLs that start with `/api/`, using `AntPathRequestMatcher` +<3> Allow access to URLs that start with `/user/` to users with the `USER` role, using `AntPathRequestMatcher` +<4> Allow access to URLs that start with `/admin/` to users with the `ADMIN` role, using `RegexRequestMatcher` +<5> Allow access to URLs that match the `MyCustomRequestMatcher` to users with the `SUPERVISOR` role, using a custom `RequestMatcher` diff --git a/etc/checkstyle/checkstyle.xml b/etc/checkstyle/checkstyle.xml index 40bce12ae5..c827af5f4d 100644 --- a/etc/checkstyle/checkstyle.xml +++ b/etc/checkstyle/checkstyle.xml @@ -18,6 +18,8 @@ + + diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/AntPathRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/AntPathRequestMatcher.java index 6d9c226b57..1c5c8e650c 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/AntPathRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/AntPathRequestMatcher.java @@ -67,6 +67,43 @@ public final class AntPathRequestMatcher implements RequestMatcher, RequestVaria private final UrlPathHelper urlPathHelper; + /** + * Creates a matcher with the specific pattern which will match all HTTP methods in a + * case-sensitive manner. + * @param pattern the ant pattern to use for matching + * @since 5.8 + */ + public static AntPathRequestMatcher antMatcher(String pattern) { + Assert.hasText(pattern, "pattern cannot be empty"); + return new AntPathRequestMatcher(pattern); + } + + /** + * Creates a matcher that will match all request with the supplied HTTP method in a + * case-sensitive manner. + * @param method the HTTP method. The {@code matches} method will return false if the + * incoming request doesn't have the same method. + * @since 5.8 + */ + public static AntPathRequestMatcher antMatcher(HttpMethod method) { + Assert.notNull(method, "method cannot be null"); + return new AntPathRequestMatcher(MATCH_ALL, method.name()); + } + + /** + * Creates a matcher with the supplied pattern and HTTP method in a case-sensitive + * manner. + * @param method the HTTP method. The {@code matches} method will return false if the + * incoming request doesn't have the same method. + * @param pattern the ant pattern to use for matching + * @since 5.8 + */ + public static AntPathRequestMatcher antMatcher(HttpMethod method, String pattern) { + Assert.notNull(method, "method cannot be null"); + Assert.hasText(pattern, "pattern cannot be empty"); + return new AntPathRequestMatcher(pattern, method.name()); + } + /** * Creates a matcher with the specific pattern which will match all HTTP methods in a * case sensitive manner. diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/RegexRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/RegexRequestMatcher.java index a334afc736..e793134dc9 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/RegexRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/RegexRequestMatcher.java @@ -25,6 +25,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.core.log.LogMessage; import org.springframework.http.HttpMethod; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** @@ -53,6 +54,38 @@ public final class RegexRequestMatcher implements RequestMatcher { private final HttpMethod httpMethod; + /** + * Creates a case-sensitive {@code Pattern} instance to match against the request. + * @param pattern the regular expression to compile into a pattern. + * @since 5.8 + */ + public static RegexRequestMatcher regexMatcher(String pattern) { + Assert.hasText(pattern, "pattern cannot be empty"); + return new RegexRequestMatcher(pattern, null); + } + + /** + * Creates an instance that matches to all requests with the same {@link HttpMethod}. + * @param method the HTTP method to match. Must not be null. + * @since 5.8 + */ + public static RegexRequestMatcher regexMatcher(HttpMethod method) { + Assert.notNull(method, "method cannot be null"); + return new RegexRequestMatcher(".*", method.name()); + } + + /** + * Creates a case-sensitive {@code Pattern} instance to match against the request. + * @param method the HTTP method to match. May be null to match all methods. + * @param pattern the regular expression to compile into a pattern. + * @since 5.8 + */ + public static RegexRequestMatcher regexMatcher(HttpMethod method, String pattern) { + Assert.notNull(method, "method cannot be null"); + Assert.hasText(pattern, "pattern cannot be empty"); + return new RegexRequestMatcher(pattern, method.name()); + } + /** * Creates a case-sensitive {@code Pattern} instance to match against the request. * @param pattern the regular expression to compile into a pattern. diff --git a/web/src/test/java/org/springframework/security/web/util/matcher/AntPathRequestMatcherTests.java b/web/src/test/java/org/springframework/security/web/util/matcher/AntPathRequestMatcherTests.java index 5046947890..6b8db3d5d1 100644 --- a/web/src/test/java/org/springframework/security/web/util/matcher/AntPathRequestMatcherTests.java +++ b/web/src/test/java/org/springframework/security/web/util/matcher/AntPathRequestMatcherTests.java @@ -23,11 +23,15 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.util.UrlPathHelper; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.BDDMockito.given; +import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; /** * @author Luke Taylor @@ -205,6 +209,48 @@ public class AntPathRequestMatcherTests { assertThat(matcher.matcher(request).isMatch()).isTrue(); } + @Test + public void staticAntMatcherWhenPatternProvidedThenPattern() { + AntPathRequestMatcher matcher = antMatcher("/path"); + assertThat(matcher.getPattern()).isEqualTo("/path"); + } + + @Test + public void staticAntMatcherWhenMethodProvidedThenMatchAll() { + AntPathRequestMatcher matcher = antMatcher(HttpMethod.GET); + assertThat(ReflectionTestUtils.getField(matcher, "httpMethod")).isEqualTo(HttpMethod.GET); + } + + @Test + public void staticAntMatcherWhenMethodAndPatternProvidedThenMatchAll() { + AntPathRequestMatcher matcher = antMatcher(HttpMethod.POST, "/path"); + assertThat(matcher.getPattern()).isEqualTo("/path"); + assertThat(ReflectionTestUtils.getField(matcher, "httpMethod")).isEqualTo(HttpMethod.POST); + } + + @Test + public void staticAntMatcherWhenMethodNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> antMatcher((HttpMethod) null)) + .withMessage("method cannot be null"); + } + + @Test + public void staticAntMatcherWhenPatternNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> antMatcher((String) null)) + .withMessage("pattern cannot be empty"); + } + + @Test + public void forMethodWhenMethodThenMatches() { + AntPathRequestMatcher matcher = antMatcher(HttpMethod.POST); + MockHttpServletRequest request = createRequest("/path"); + assertThat(matcher.matches(request)).isTrue(); + request.setServletPath("/another-path/second"); + assertThat(matcher.matches(request)).isTrue(); + request.setMethod("GET"); + assertThat(matcher.matches(request)).isFalse(); + } + private HttpServletRequest createRequestWithNullMethod(String path) { given(this.request.getServletPath()).willReturn(path); return this.request; diff --git a/web/src/test/java/org/springframework/security/web/util/matcher/RegexRequestMatcherTests.java b/web/src/test/java/org/springframework/security/web/util/matcher/RegexRequestMatcherTests.java index 66f0a3d641..b49abdf605 100644 --- a/web/src/test/java/org/springframework/security/web/util/matcher/RegexRequestMatcherTests.java +++ b/web/src/test/java/org/springframework/security/web/util/matcher/RegexRequestMatcherTests.java @@ -23,10 +23,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockHttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.BDDMockito.given; +import static org.springframework.security.web.util.matcher.RegexRequestMatcher.regexMatcher; /** * @author Luke Taylor @@ -123,6 +126,46 @@ public class RegexRequestMatcherTests { assertThat(matcher.toString()).isEqualTo("Regex [pattern='/blah', GET]"); } + @Test + public void matchesWhenRequestUriMatchesThenMatchesTrue() { + RegexRequestMatcher matcher = regexMatcher(".*"); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/something/anything"); + assertThat(matcher.matches(request)).isTrue(); + } + + @Test + public void matchesWhenRequestUriDontMatchThenMatchesFalse() { + RegexRequestMatcher matcher = regexMatcher(".*\\?param=value"); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/something/anything"); + assertThat(matcher.matches(request)).isFalse(); + } + + @Test + public void matchesWhenRequestMethodMatchesThenMatchesTrue() { + RegexRequestMatcher matcher = regexMatcher(HttpMethod.GET); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/something/anything"); + assertThat(matcher.matches(request)).isTrue(); + } + + @Test + public void matchesWhenRequestMethodDontMatchThenMatchesFalse() { + RegexRequestMatcher matcher = regexMatcher(HttpMethod.POST); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/something/anything"); + assertThat(matcher.matches(request)).isFalse(); + } + + @Test + public void staticRegexMatcherWhenNoPatternThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> regexMatcher((String) null)) + .withMessage("pattern cannot be empty"); + } + + @Test + public void staticRegexMatcherNoMethodThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> regexMatcher((HttpMethod) null)) + .withMessage("method cannot be null"); + } + private HttpServletRequest createRequestWithNullMethod(String path) { given(this.request.getQueryString()).willReturn("doesntMatter"); given(this.request.getServletPath()).willReturn(path);