From 763a0eaa921cb00bae0ea6cb9574c12262fe33b3 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:15:38 -0700 Subject: [PATCH] Add PathPatternRequestMatcher Closes gh-16429 --- .../matcher/PathPatternRequestMatcher.java | 207 ++++++++++++++++++ .../util/matcher/RequestMatcherBuilder.java | 48 ++++ .../PathPatternRequestMatcherTests.java | 93 ++++++++ 3 files changed, 348 insertions(+) create mode 100644 web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java create mode 100644 web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java create mode 100644 web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java diff --git a/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java new file mode 100644 index 0000000000..7d7ba3e34a --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java @@ -0,0 +1,207 @@ +/* + * 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.util.Objects; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherBuilder; +import org.springframework.util.Assert; +import org.springframework.web.util.ServletRequestPathUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * A {@link RequestMatcher} that uses {@link PathPattern}s to match against each + * {@link HttpServletRequest}. Specifically, this means that the class anticipates that + * the provided pattern does not include the servlet path in order to align with Spring + * MVC. + * + *

+ * Note that the {@link org.springframework.web.servlet.HandlerMapping} that contains the + * related URI patterns must be using the same + * {@link org.springframework.web.util.pattern.PathPatternParser} configured in this + * class. + *

+ * + * @author Josh Cummings + * @since 6.5 + */ +public final class PathPatternRequestMatcher implements RequestMatcher { + + private static final String PATH_ATTRIBUTE = PathPatternRequestMatcher.class + ".PATH"; + + static final String ANY_SERVLET = new String(); + + private final PathPattern pattern; + + private String servletPath; + + private HttpMethod method; + + PathPatternRequestMatcher(PathPattern pattern) { + this.pattern = pattern; + } + + /** + * Create a {@link Builder} for creating {@link PathPattern}-based request matchers. + * That is, matchers that anticipate patterns do not specify the servlet path. + * @return the {@link Builder} + */ + public static Builder builder() { + return new Builder(PathPatternParser.defaultInstance); + } + + /** + * Create a {@link Builder} for creating {@link PathPattern}-based request matchers. + * That is, matchers that anticipate patterns do not specify the servlet path. + * @param parser the {@link PathPatternParser}; only needed when different from + * {@link PathPatternParser#defaultInstance} + * @return the {@link Builder} + */ + public static Builder withPathPatternParser(PathPatternParser parser) { + return new Builder(parser); + } + + @Override + public boolean matches(HttpServletRequest request) { + return matcher(request).isMatch(); + } + + @Override + public MatchResult matcher(HttpServletRequest request) { + if (this.method != null && !this.method.name().equals(request.getMethod())) { + return MatchResult.notMatch(); + } + if (this.servletPath != null && !this.servletPath.equals(request.getServletPath()) + && !ANY_SERVLET.equals(this.servletPath)) { + return MatchResult.notMatch(); + } + PathContainer path = getPathContainer(request); + PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(path); + return (info != null) ? MatchResult.match(info.getUriVariables()) : MatchResult.notMatch(); + } + + PathContainer getPathContainer(HttpServletRequest request) { + if (this.servletPath != null) { + return ServletRequestPathUtils.parseAndCache(request).pathWithinApplication(); + } + else { + return parseAndCache(request); + } + } + + PathContainer parseAndCache(HttpServletRequest request) { + PathContainer path = (PathContainer) request.getAttribute(PATH_ATTRIBUTE); + if (path != null) { + return path; + } + path = RequestPath.parse(request.getRequestURI(), request.getContextPath()).pathWithinApplication(); + request.setAttribute(PATH_ATTRIBUTE, path); + return path; + } + + void setServletPath(String servletPath) { + this.servletPath = servletPath; + } + + void setMethod(HttpMethod method) { + this.method = method; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof PathPatternRequestMatcher that)) { + return false; + } + return Objects.equals(this.pattern, that.pattern) && Objects.equals(this.servletPath, that.servletPath) + && Objects.equals(this.method, that.method); + } + + @Override + public int hashCode() { + return Objects.hash(this.pattern, this.servletPath, this.method); + } + + @Override + public String toString() { + return "PathPatternRequestMatcher [pattern=" + this.pattern + ", servletPath=" + this.servletPath + ", method=" + + this.method + ']'; + } + + /** + * A builder for {@link MvcRequestMatcher} + * + * @author Marcus Da Coregio + * @since 6.5 + */ + public static final class Builder implements RequestMatcherBuilder { + + private final PathPatternParser parser; + + private HttpMethod method; + + private String servletPath; + + /** + * Construct a new instance of this builder + */ + public Builder(PathPatternParser parser) { + Assert.notNull(parser, "pathPatternParser cannot be null"); + this.parser = parser; + } + + public Builder method(HttpMethod method) { + this.method = method; + return this; + } + + /** + * Sets the servlet path to be used by the {@link MvcRequestMatcher} generated by + * this builder + * @param servletPath the servlet path to use + * @return the {@link MvcRequestMatcher.Builder} for further configuration + */ + public Builder servletPath(String servletPath) { + this.servletPath = servletPath; + return this; + } + + /** + * Creates an {@link MvcRequestMatcher} that uses the provided pattern and HTTP + * method to match + * @param method the {@link HttpMethod}, can be null + * @param pattern the patterns used to match + * @return the generated {@link MvcRequestMatcher} + */ + public PathPatternRequestMatcher pattern(HttpMethod method, String pattern) { + String parsed = this.parser.initFullPathPattern(pattern); + PathPattern pathPattern = this.parser.parse(parsed); + PathPatternRequestMatcher requestMatcher = new PathPatternRequestMatcher(pathPattern); + requestMatcher.setServletPath(this.servletPath); + requestMatcher.setMethod(method); + return requestMatcher; + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java b/web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java new file mode 100644 index 0000000000..31ff6957a5 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcherBuilder.java @@ -0,0 +1,48 @@ +/* + * 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.util.matcher; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.http.HttpMethod; + +public interface RequestMatcherBuilder { + + default RequestMatcher[] pattern(HttpMethod method, String... patterns) { + List requestMatchers = new ArrayList<>(); + for (String pattern : patterns) { + requestMatchers.add(pattern(method, pattern)); + } + return requestMatchers.toArray(RequestMatcher[]::new); + } + + default RequestMatcher[] pattern(String... patterns) { + return pattern(null, patterns); + } + + default RequestMatcher pattern(String pattern) { + return pattern(null, pattern); + } + + default RequestMatcher anyRequest() { + return pattern(null, "/**"); + } + + RequestMatcher pattern(HttpMethod method, String pattern); + +} diff --git a/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java new file mode 100644 index 0000000000..998a738f33 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java @@ -0,0 +1,93 @@ +/* + * 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.MappingMatch; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletMapping; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PathPatternRequestMatcher} + */ +public class PathPatternRequestMatcherTests { + + @Test + void matcherWhenPatternMatchesRequestThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern("/uri"); + assertThat(matcher.matches(request("GET", "/uri"))).isTrue(); + } + + @Test + void matcherWhenPatternContainsPlaceholdersThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern("/uri/{username}"); + assertThat(matcher.matcher(request("GET", "/uri/bob")).getVariables()).containsEntry("username", "bob"); + } + + @Test + void matcherWhenSameServletPathThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().servletPath("/mvc").pattern("/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isTrue(); + } + + @Test + void matcherWhenSameMethodThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/uri"))).isTrue(); + } + + @Test + void matcherWhenDifferentPathThenNotMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder() + .servletPath("/mvc") + .pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/uri", ""))).isFalse(); + } + + @Test + void matcherWhenDifferentMethodThenNotMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.builder() + .servletPath("/mvc") + .pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("POST", "/mvc/uri", "/mvc"))).isFalse(); + } + + @Test + void matcherWhenNoServletPathThenMatchAbsolute() { + RequestMatcher matcher = PathPatternRequestMatcher.builder().pattern(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isFalse(); + assertThat(matcher.matches(request("GET", "/uri", ""))).isTrue(); + } + + MockHttpServletRequest request(String method, String uri) { + return new MockHttpServletRequest(method, uri); + } + + MockHttpServletRequest request(String method, String uri, String servletPath) { + MockHttpServletRequest request = new MockHttpServletRequest(method, uri); + request.setServletPath(servletPath); + request + .setHttpServletMapping(new MockHttpServletMapping(uri, servletPath + "/*", "servlet", MappingMatch.PATH)); + return request; + } + +}