diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java index 4f849f86fb1..41f60854a55 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java @@ -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. @@ -43,6 +43,7 @@ import org.springframework.security.config.annotation.web.ServletRegistrationsSupport.RegistrationMapping; import org.springframework.security.config.annotation.web.configurers.AbstractConfigAttributeRequestMatcherRegistry; 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.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher; @@ -218,10 +219,9 @@ public C requestMatchers(HttpMethod method, String... patterns) { return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns)); } List matchers = new ArrayList<>(); + MethodPathRequestMatcherFactory requestMatcherFactory = getRequestMatcherFactory(); 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)); + matchers.add(requestMatcherFactory.matcher(method, pattern)); } return requestMatchers(matchers.toArray(new RequestMatcher[0])); } @@ -264,11 +264,13 @@ private RequestMatcher resolve(AntPathRequestMatcher ant, MvcRequestMatcher mvc, } private static String computeErrorMessage(Collection 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 PathPatternRequestMatcher#servletPath for each servlet that has \ + authorized endpoints and use them to construct request matchers manually. + """; Map> mappings = new LinkedHashMap<>(); for (ServletRegistration registration : registrations) { mappings.put(registration.getClassName(), registration.getMappings()); @@ -329,6 +331,13 @@ public C requestMatchers(HttpMethod method) { */ protected abstract C chainRequestMatchers(List requestMatchers); + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + PathPatternRequestMatcher.Builder builder = this.context + .getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + return (builder != null) ? builder::matcher : new DefaultMethodPathRequestMatcherFactory(); + } + /** * Utilities for creating {@link RequestMatcher} instances. * @@ -402,6 +411,17 @@ static List regexMatchers(String... regexPatterns) { } + class DefaultMethodPathRequestMatcherFactory implements MethodPathRequestMatcherFactory { + + @Override + public RequestMatcher matcher(HttpMethod method, String path) { + AntPathRequestMatcher ant = new AntPathRequestMatcher(path, (method != null) ? method.name() : null); + MvcRequestMatcher mvc = createMvcMatchers(method, path).get(0); + return new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant); + } + + } + static class DeferredRequestMatcher implements RequestMatcher { final Function requestMatcherFactory; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/MethodPathRequestMatcherFactory.java b/config/src/main/java/org/springframework/security/config/annotation/web/MethodPathRequestMatcherFactory.java new file mode 100644 index 00000000000..72562502358 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/MethodPathRequestMatcherFactory.java @@ -0,0 +1,39 @@ +/* + * 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.config.annotation.web; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +interface MethodPathRequestMatcherFactory { + + default RequestMatcher matcher(String path) { + return matcher(null, path); + } + + RequestMatcher matcher(HttpMethod method, String path); + + static MethodPathRequestMatcherFactory fromApplicationContext(ApplicationContext context) { + PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + return (builder != null) ? builder::matcher : AntPathRequestMatcher::antMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index f96c943d557..2565c89f24b 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -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. @@ -90,6 +90,7 @@ import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AnyRequestMatcher; @@ -3684,11 +3685,16 @@ public HttpSecurity securityMatcher(RequestMatcher requestMatcher) { * @see MvcRequestMatcher */ public HttpSecurity securityMatcher(String... patterns) { - if (mvcPresent) { - this.requestMatcher = new OrRequestMatcher(createMvcMatchers(patterns)); - return this; + List matchers = new ArrayList<>(); + PathPatternRequestMatcher.Builder builder = getSharedObject(ApplicationContext.class) + .getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + MethodPathRequestMatcherFactory factory = (builder != null) ? builder::matcher + : (method, pattern) -> mvcPresent ? createMvcMatcher(pattern) : createAntMatcher(pattern); + for (String pattern : patterns) { + matchers.add(factory.matcher(pattern)); } - this.requestMatcher = new OrRequestMatcher(createAntMatchers(patterns)); + this.requestMatcher = new OrRequestMatcher(matchers); return this; } @@ -3717,15 +3723,11 @@ public HttpSecurity webAuthn(Customizer> webAut return HttpSecurity.this; } - private List createAntMatchers(String... patterns) { - List matchers = new ArrayList<>(patterns.length); - for (String pattern : patterns) { - matchers.add(new AntPathRequestMatcher(pattern)); - } - return matchers; + private RequestMatcher createAntMatcher(String pattern) { + return new AntPathRequestMatcher(pattern); } - private List createMvcMatchers(String... mvcPatterns) { + private RequestMatcher createMvcMatcher(String mvcPattern) { ResolvableType type = ResolvableType.forClassWithGenerics(ObjectPostProcessor.class, Object.class); ObjectProvider> postProcessors = getContext().getBeanProvider(type); ObjectPostProcessor opp = postProcessors.getObject(); @@ -3736,13 +3738,9 @@ private List createMvcMatchers(String... mvcPatterns) { } HandlerMappingIntrospector introspector = getContext().getBean(HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME, HandlerMappingIntrospector.class); - List matchers = new ArrayList<>(mvcPatterns.length); - for (String mvcPattern : mvcPatterns) { - MvcRequestMatcher matcher = new MvcRequestMatcher(introspector, mvcPattern); - opp.postProcess(matcher); - matchers.add(matcher); - } - return matchers; + MvcRequestMatcher matcher = new MvcRequestMatcher(introspector, mvcPattern); + opp.postProcess(matcher); + return matcher; } /** diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/MethodPathRequestMatcherFactory.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/MethodPathRequestMatcherFactory.java new file mode 100644 index 00000000000..2711399186c --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/MethodPathRequestMatcherFactory.java @@ -0,0 +1,39 @@ +/* + * 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.config.annotation.web.builders; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +interface MethodPathRequestMatcherFactory { + + default RequestMatcher matcher(String path) { + return matcher(null, path); + } + + RequestMatcher matcher(HttpMethod method, String path); + + static MethodPathRequestMatcherFactory fromApplicationContext(ApplicationContext context) { + PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + return (builder != null) ? builder::matcher : AntPathRequestMatcher::antMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java index b28e57e4d37..01be169e829 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java @@ -16,6 +16,8 @@ package org.springframework.security.config.annotation.web.configurers; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -26,7 +28,6 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; /** @@ -234,7 +235,7 @@ public void init(H http) throws Exception { @Override protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { - return new AntPathRequestMatcher(loginProcessingUrl, "POST"); + return getRequestMatcherFactory().matcher(HttpMethod.POST, loginProcessingUrl); } /** @@ -271,4 +272,9 @@ private void initDefaultLoginFilter(H http) { } } + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory + .fromApplicationContext(getBuilder().getSharedObject(ApplicationContext.class)); + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurer.java index 04a2ac35cd1..1197c0dedf8 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurer.java @@ -22,6 +22,8 @@ import jakarta.servlet.http.HttpSession; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.SecurityConfigurer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -37,7 +39,6 @@ import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; @@ -368,7 +369,12 @@ private RequestMatcher createLogoutRequestMatcher(H http) { } private RequestMatcher createLogoutRequestMatcher(String httpMethod) { - return new AntPathRequestMatcher(this.logoutUrl, httpMethod); + return getRequestMatcherFactory().matcher(HttpMethod.valueOf(httpMethod), this.logoutUrl); + } + + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory + .fromApplicationContext(getBuilder().getSharedObject(ApplicationContext.class)); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MethodPathRequestMatcherFactory.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MethodPathRequestMatcherFactory.java new file mode 100644 index 00000000000..9fc70437381 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MethodPathRequestMatcherFactory.java @@ -0,0 +1,39 @@ +/* + * 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.config.annotation.web.configurers; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +interface MethodPathRequestMatcherFactory { + + default RequestMatcher matcher(String path) { + return matcher(null, path); + } + + RequestMatcher matcher(HttpMethod method, String path); + + static MethodPathRequestMatcherFactory fromApplicationContext(ApplicationContext context) { + PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + return (builder != null) ? builder::matcher : AntPathRequestMatcher::antMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java index 0f9b52f6570..90847f1b30f 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java @@ -16,10 +16,10 @@ package org.springframework.security.config.annotation.web.configurers; +import org.springframework.context.ApplicationContext; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.web.RequestMatcherRedirectFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.util.Assert; /** @@ -55,8 +55,13 @@ public PasswordManagementConfigurer changePasswordPage(String changePasswordP @Override public void configure(B http) throws Exception { RequestMatcherRedirectFilter changePasswordFilter = new RequestMatcherRedirectFilter( - new AntPathRequestMatcher(WELL_KNOWN_CHANGE_PASSWORD_PATTERN), this.changePasswordPage); + getRequestMatcherFactory().matcher(WELL_KNOWN_CHANGE_PASSWORD_PATTERN), this.changePasswordPage); http.addFilterBefore(postProcess(changePasswordFilter), UsernamePasswordAuthenticationFilter.class); } + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory + .fromApplicationContext(getBuilder().getSharedObject(ApplicationContext.class)); + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurer.java index 712c89073f8..adf850c14bb 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurer.java @@ -19,15 +19,22 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.regex.Pattern; + +import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.NullRequestCache; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.savedrequest.RequestCacheAwareFilter; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.util.matcher.AndRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; @@ -36,6 +43,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.accept.ContentNegotiationStrategy; import org.springframework.web.accept.HeaderContentNegotiationStrategy; +import org.springframework.web.util.ServletRequestPathUtils; /** * Adds request cache for Spring Security. Specifically this ensures that requests that @@ -140,13 +148,13 @@ private T getBeanOrNull(Class type) { @SuppressWarnings("unchecked") private RequestMatcher createDefaultSavedRequestMatcher(H http) { - RequestMatcher notFavIcon = new NegatedRequestMatcher(new AntPathRequestMatcher("/**/favicon.*")); + RequestMatcher notFavIcon = new NegatedRequestMatcher(getFaviconRequestMatcher()); RequestMatcher notXRequestedWith = new NegatedRequestMatcher( new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest")); boolean isCsrfEnabled = http.getConfigurer(CsrfConfigurer.class) != null; List matchers = new ArrayList<>(); if (isCsrfEnabled) { - RequestMatcher getRequests = new AntPathRequestMatcher("/**", "GET"); + RequestMatcher getRequests = getRequestMatcherFactory().matcher(HttpMethod.GET, "/**"); matchers.add(0, getRequests); } matchers.add(notFavIcon); @@ -167,4 +175,39 @@ private RequestMatcher notMatchingMediaType(H http, MediaType mediaType) { return new NegatedRequestMatcher(mediaRequest); } + private RequestMatcher getFaviconRequestMatcher() { + PathPatternRequestMatcher.Builder builder = getBuilder().getSharedObject(ApplicationContext.class) + .getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + if (builder == null) { + return new AntPathRequestMatcher("/**/favicon.*"); + } + else { + return new FilenameRequestMatcher(Pattern.compile("favicon.*")); + } + } + + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + return MethodPathRequestMatcherFactory.fromApplicationContext(context); + } + + private static final class FilenameRequestMatcher implements RequestMatcher { + + private final Pattern pattern; + + FilenameRequestMatcher(Pattern pattern) { + this.pattern = pattern; + } + + @Override + public boolean matches(HttpServletRequest request) { + RequestPath path = ServletRequestPathUtils.getParsedRequestPath(request); + List elements = path.elements(); + String file = elements.get(elements.size() - 1).value(); + return this.pattern.matcher(file).matches(); + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/MethodPathRequestMatcherFactory.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/MethodPathRequestMatcherFactory.java new file mode 100644 index 00000000000..130a9bcc3c2 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/MethodPathRequestMatcherFactory.java @@ -0,0 +1,39 @@ +/* + * 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.config.annotation.web.configurers.oauth2.client; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +interface MethodPathRequestMatcherFactory { + + default RequestMatcher matcher(String path) { + return matcher(null, path); + } + + RequestMatcher matcher(HttpMethod method, String path); + + static MethodPathRequestMatcherFactory fromApplicationContext(ApplicationContext context) { + PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + return (builder != null) ? builder::matcher : AntPathRequestMatcher::antMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java index 4c53b3293d0..54cef424ed9 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java @@ -91,7 +91,6 @@ import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.util.matcher.AndRequestMatcher; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.security.web.util.matcher.NegatedRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; @@ -431,7 +430,7 @@ public void configure(B http) throws Exception { @Override protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { - return new AntPathRequestMatcher(loginProcessingUrl); + return getRequestMatcherFactory().matcher(loginProcessingUrl); } private OAuth2AuthorizationRequestResolver getAuthorizationRequestResolver() { @@ -569,8 +568,8 @@ private Map getLoginLinks() { } private AuthenticationEntryPoint getLoginEntryPoint(B http, String providerLoginPage) { - RequestMatcher loginPageMatcher = new AntPathRequestMatcher(this.getLoginPage()); - RequestMatcher faviconMatcher = new AntPathRequestMatcher("/favicon.ico"); + RequestMatcher loginPageMatcher = getRequestMatcherFactory().matcher(this.getLoginPage()); + RequestMatcher faviconMatcher = getRequestMatcherFactory().matcher("/favicon.ico"); RequestMatcher defaultEntryPointMatcher = this.getAuthenticationEntryPointMatcher(http); RequestMatcher defaultLoginPageMatcher = new AndRequestMatcher( new OrRequestMatcher(loginPageMatcher, faviconMatcher), defaultEntryPointMatcher); @@ -625,6 +624,11 @@ private void registerDelegateApplicationListener(ApplicationListener delegate delegating.addListener(smartListener); } + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory + .fromApplicationContext(getBuilder().getSharedObject(ApplicationContext.class)); + } + /** * Configuration options for the Authorization Server's Authorization Endpoint. */ diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/MethodPathRequestMatcherFactory.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/MethodPathRequestMatcherFactory.java new file mode 100644 index 00000000000..ab152435c7e --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/MethodPathRequestMatcherFactory.java @@ -0,0 +1,39 @@ +/* + * 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.config.annotation.web.configurers.ott; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +interface MethodPathRequestMatcherFactory { + + default RequestMatcher matcher(String path) { + return matcher(null, path); + } + + RequestMatcher matcher(HttpMethod method, String path); + + static MethodPathRequestMatcherFactory fromApplicationContext(ApplicationContext context) { + PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + return (builder != null) ? builder::matcher : AntPathRequestMatcher::antMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java index 6f1e02ca6ed..2a1c5cb5426 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java @@ -56,8 +56,6 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; -import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; - /** * An {@link AbstractHttpConfigurer} for One-Time Token Login. * @@ -123,8 +121,9 @@ public final class OneTimeTokenLoginConfigurer> private GenerateOneTimeTokenRequestResolver requestResolver; public OneTimeTokenLoginConfigurer(ApplicationContext context) { - super(new OneTimeTokenAuthenticationFilter(), OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL); + super(new OneTimeTokenAuthenticationFilter(), null); this.context = context; + loginProcessingUrl(OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL); } @Override @@ -163,7 +162,7 @@ public void configure(H http) throws Exception { private void configureOttGenerateFilter(H http) { GenerateOneTimeTokenFilter generateFilter = new GenerateOneTimeTokenFilter(getOneTimeTokenService(), getOneTimeTokenGenerationSuccessHandler()); - generateFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.tokenGeneratingUrl)); + generateFilter.setRequestMatcher(getRequestMatcherFactory().matcher(HttpMethod.POST, this.tokenGeneratingUrl)); generateFilter.setRequestResolver(getGenerateRequestResolver()); http.addFilter(postProcess(generateFilter)); http.addFilter(DefaultResourcesFilter.css()); @@ -190,7 +189,7 @@ private void configureSubmitPage(H http) { } DefaultOneTimeTokenSubmitPageGeneratingFilter submitPage = new DefaultOneTimeTokenSubmitPageGeneratingFilter(); submitPage.setResolveHiddenInputs(this::hiddenInputs); - submitPage.setRequestMatcher(antMatcher(HttpMethod.GET, this.defaultSubmitPageUrl)); + submitPage.setRequestMatcher(getRequestMatcherFactory().matcher(HttpMethod.GET, this.defaultSubmitPageUrl)); submitPage.setLoginProcessingUrl(this.getLoginProcessingUrl()); http.addFilter(postProcess(submitPage)); } @@ -207,7 +206,11 @@ private AuthenticationProvider getAuthenticationProvider() { @Override protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { - return antMatcher(HttpMethod.POST, loginProcessingUrl); + return getRequestMatcherFactory().matcher(HttpMethod.POST, loginProcessingUrl); + } + + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory.fromApplicationContext(this.context); } /** diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/MethodPathRequestMatcherFactory.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/MethodPathRequestMatcherFactory.java new file mode 100644 index 00000000000..67a9c565ce6 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/MethodPathRequestMatcherFactory.java @@ -0,0 +1,39 @@ +/* + * 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.config.annotation.web.configurers.saml2; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +interface MethodPathRequestMatcherFactory { + + default RequestMatcher matcher(String path) { + return matcher(null, path); + } + + RequestMatcher matcher(HttpMethod method, String path); + + static MethodPathRequestMatcherFactory fromApplicationContext(ApplicationContext context) { + PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(); + return (builder != null) ? builder::matcher : AntPathRequestMatcher::antMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index b07b034d143..fa1af4f0119 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -56,7 +56,6 @@ import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.util.matcher.AndRequestMatcher; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.NegatedRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.ParameterRequestMatcher; @@ -127,15 +126,11 @@ public final class Saml2LoginConfigurer> private String[] authenticationRequestParams = { "registrationId={registrationId}" }; - private RequestMatcher authenticationRequestMatcher = RequestMatchers.anyOf( - new AntPathRequestMatcher(Saml2AuthenticationRequestResolver.DEFAULT_AUTHENTICATION_REQUEST_URI), - new AntPathQueryRequestMatcher(this.authenticationRequestUri, this.authenticationRequestParams)); + private RequestMatcher authenticationRequestMatcher; private Saml2AuthenticationRequestResolver authenticationRequestResolver; - private RequestMatcher loginProcessingUrl = RequestMatchers.anyOf( - new AntPathRequestMatcher(Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI), - new AntPathRequestMatcher("/login/saml2/sso")); + private RequestMatcher loginProcessingUrl; private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; @@ -238,8 +233,8 @@ public Saml2LoginConfigurer authenticationRequestUriQuery(String authenticati this.authenticationRequestUri = parts[0]; this.authenticationRequestParams = new String[parts.length - 1]; System.arraycopy(parts, 1, this.authenticationRequestParams, 0, parts.length - 1); - this.authenticationRequestMatcher = new AntPathQueryRequestMatcher(this.authenticationRequestUri, - this.authenticationRequestParams); + this.authenticationRequestMatcher = new AntPathQueryRequestMatcher( + getRequestMatcherFactory().matcher(this.authenticationRequestUri), this.authenticationRequestParams); return this; } @@ -256,13 +251,13 @@ public Saml2LoginConfigurer authenticationRequestUriQuery(String authenticati @Override public Saml2LoginConfigurer loginProcessingUrl(String loginProcessingUrl) { Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be empty"); - this.loginProcessingUrl = new AntPathRequestMatcher(loginProcessingUrl); + this.loginProcessingUrl = getRequestMatcherFactory().matcher(loginProcessingUrl); return this; } @Override protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { - return new AntPathRequestMatcher(loginProcessingUrl); + return getRequestMatcherFactory().matcher(loginProcessingUrl); } /** @@ -284,7 +279,7 @@ public void init(B http) throws Exception { relyingPartyRegistrationRepository(http); this.saml2WebSsoAuthenticationFilter = new Saml2WebSsoAuthenticationFilter(getAuthenticationConverter(http)); this.saml2WebSsoAuthenticationFilter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy()); - this.saml2WebSsoAuthenticationFilter.setRequiresAuthenticationRequestMatcher(this.loginProcessingUrl); + this.saml2WebSsoAuthenticationFilter.setRequiresAuthenticationRequestMatcher(getLoginProcessingEndpoint()); setAuthenticationRequestRepository(http, this.saml2WebSsoAuthenticationFilter); setAuthenticationFilter(this.saml2WebSsoAuthenticationFilter); if (StringUtils.hasText(this.loginPage)) { @@ -340,8 +335,8 @@ RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(B http) { } private AuthenticationEntryPoint getLoginEntryPoint(B http, String providerLoginPage) { - RequestMatcher loginPageMatcher = new AntPathRequestMatcher(this.getLoginPage()); - RequestMatcher faviconMatcher = new AntPathRequestMatcher("/favicon.ico"); + RequestMatcher loginPageMatcher = getRequestMatcherFactory().matcher(this.getLoginPage()); + RequestMatcher faviconMatcher = getRequestMatcherFactory().matcher("/favicon.ico"); RequestMatcher defaultEntryPointMatcher = this.getAuthenticationEntryPointMatcher(http); RequestMatcher defaultLoginPageMatcher = new AndRequestMatcher( new OrRequestMatcher(loginPageMatcher, faviconMatcher), defaultEntryPointMatcher); @@ -376,17 +371,38 @@ private Saml2AuthenticationRequestResolver getAuthenticationRequestResolver(B ht if (USE_OPENSAML_5) { OpenSaml5AuthenticationRequestResolver openSamlAuthenticationRequestResolver = new OpenSaml5AuthenticationRequestResolver( relyingPartyRegistrationRepository(http)); - openSamlAuthenticationRequestResolver.setRequestMatcher(this.authenticationRequestMatcher); + openSamlAuthenticationRequestResolver.setRequestMatcher(getAuthenticationRequestMatcher()); return openSamlAuthenticationRequestResolver; } else { OpenSaml4AuthenticationRequestResolver openSamlAuthenticationRequestResolver = new OpenSaml4AuthenticationRequestResolver( relyingPartyRegistrationRepository(http)); - openSamlAuthenticationRequestResolver.setRequestMatcher(this.authenticationRequestMatcher); + openSamlAuthenticationRequestResolver.setRequestMatcher(getAuthenticationRequestMatcher()); return openSamlAuthenticationRequestResolver; } } + private RequestMatcher getAuthenticationRequestMatcher() { + if (this.authenticationRequestMatcher == null) { + this.authenticationRequestMatcher = RequestMatchers.anyOf( + getRequestMatcherFactory() + .matcher(Saml2AuthenticationRequestResolver.DEFAULT_AUTHENTICATION_REQUEST_URI), + new AntPathQueryRequestMatcher(getRequestMatcherFactory().matcher(this.authenticationRequestUri), + this.authenticationRequestParams)); + } + return this.authenticationRequestMatcher; + } + + private RequestMatcher getLoginProcessingEndpoint() { + if (this.loginProcessingUrl == null) { + this.loginProcessingUrl = RequestMatchers.anyOf( + getRequestMatcherFactory().matcher(Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI), + getRequestMatcherFactory().matcher("/login/saml2/sso")); + } + + return this.loginProcessingUrl; + } + private AuthenticationConverter getAuthenticationConverter(B http) { if (this.authenticationConverter != null) { return this.authenticationConverter; @@ -407,7 +423,7 @@ private AuthenticationConverter getAuthenticationConverter(B http) { OpenSaml5AuthenticationTokenConverter converter = new OpenSaml5AuthenticationTokenConverter( this.relyingPartyRegistrationRepository); converter.setAuthenticationRequestRepository(getAuthenticationRequestRepository(http)); - converter.setRequestMatcher(this.loginProcessingUrl); + converter.setRequestMatcher(getLoginProcessingEndpoint()); return converter; } authenticationConverterBean = getBeanOrNull(http, OpenSaml4AuthenticationTokenConverter.class); @@ -417,7 +433,7 @@ private AuthenticationConverter getAuthenticationConverter(B http) { OpenSaml4AuthenticationTokenConverter converter = new OpenSaml4AuthenticationTokenConverter( this.relyingPartyRegistrationRepository); converter.setAuthenticationRequestRepository(getAuthenticationRequestRepository(http)); - converter.setRequestMatcher(this.loginProcessingUrl); + converter.setRequestMatcher(getLoginProcessingEndpoint()); return converter; } @@ -441,7 +457,7 @@ private void registerDefaultCsrfOverride(B http) { if (csrf == null) { return; } - csrf.ignoringRequestMatchers(this.loginProcessingUrl); + csrf.ignoringRequestMatchers(getLoginProcessingEndpoint()); } private void initDefaultLoginFilter(B http) { @@ -487,6 +503,11 @@ private Saml2AuthenticationRequestRepository return repository; } + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory + .fromApplicationContext(getBuilder().getSharedObject(ApplicationContext.class)); + } + private C getSharedOrBean(B http, Class clazz) { C shared = http.getSharedObject(clazz); if (shared != null) { @@ -513,9 +534,9 @@ static class AntPathQueryRequestMatcher implements RequestMatcher { private final RequestMatcher matcher; - AntPathQueryRequestMatcher(String path, String... params) { + AntPathQueryRequestMatcher(RequestMatcher pathMatcher, String... params) { List matchers = new ArrayList<>(); - matchers.add(new AntPathRequestMatcher(path)); + matchers.add(pathMatcher); for (String param : params) { String[] parts = param.split("="); if (parts.length == 1) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java index 92c7cef819f..3543cc8b507 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java @@ -23,6 +23,7 @@ import org.opensaml.core.Version; import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; @@ -64,7 +65,6 @@ import org.springframework.security.web.csrf.CsrfLogoutHandler; import org.springframework.security.web.csrf.CsrfTokenRepository; import org.springframework.security.web.util.matcher.AndRequestMatcher; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.ParameterRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -304,19 +304,19 @@ private Saml2RelyingPartyInitiatedLogoutFilter createRelyingPartyLogoutFilter( } private RequestMatcher createLogoutMatcher() { - RequestMatcher logout = new AntPathRequestMatcher(this.logoutUrl, "POST"); + RequestMatcher logout = getRequestMatcherFactory().matcher(HttpMethod.POST, this.logoutUrl); RequestMatcher saml2 = new Saml2RequestMatcher(getSecurityContextHolderStrategy()); return new AndRequestMatcher(logout, saml2); } private RequestMatcher createLogoutRequestMatcher() { - RequestMatcher logout = new AntPathRequestMatcher(this.logoutRequestConfigurer.logoutUrl); + RequestMatcher logout = getRequestMatcherFactory().matcher(this.logoutRequestConfigurer.logoutUrl); RequestMatcher samlRequest = new ParameterRequestMatcher("SAMLRequest"); return new AndRequestMatcher(logout, samlRequest); } private RequestMatcher createLogoutResponseMatcher() { - RequestMatcher logout = new AntPathRequestMatcher(this.logoutResponseConfigurer.logoutUrl); + RequestMatcher logout = getRequestMatcherFactory().matcher(this.logoutResponseConfigurer.logoutUrl); RequestMatcher samlResponse = new ParameterRequestMatcher("SAMLResponse"); return new AndRequestMatcher(logout, samlResponse); } @@ -333,6 +333,11 @@ private Saml2LogoutResponseResolver createSaml2LogoutResponseResolver( return this.logoutResponseConfigurer.logoutResponseResolver(registrations); } + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory + .fromApplicationContext(getBuilder().getSharedObject(ApplicationContext.class)); + } + private C getBeanOrNull(Class clazz) { if (this.context == null) { return null; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java index 349e3a66066..c4d2b90fc8f 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java @@ -32,7 +32,6 @@ import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter; import org.springframework.security.saml2.provider.service.web.metadata.RequestMatcherMetadataResponseResolver; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.util.Assert; /** @@ -111,12 +110,12 @@ public Saml2MetadataConfigurer metadataUrl(String metadataUrl) { if (USE_OPENSAML_5) { RequestMatcherMetadataResponseResolver metadata = new RequestMatcherMetadataResponseResolver( registrations, new OpenSaml5MetadataResolver()); - metadata.setRequestMatcher(new AntPathRequestMatcher(metadataUrl)); + metadata.setRequestMatcher(getRequestMatcherFactory().matcher(metadataUrl)); return metadata; } RequestMatcherMetadataResponseResolver metadata = new RequestMatcherMetadataResponseResolver(registrations, new OpenSaml4MetadataResolver()); - metadata.setRequestMatcher(new AntPathRequestMatcher(metadataUrl)); + metadata.setRequestMatcher(getRequestMatcherFactory().matcher(metadataUrl)); return metadata; }; return this; @@ -170,6 +169,11 @@ private RelyingPartyRegistrationRepository getRelyingPartyRegistrationRepository } } + private MethodPathRequestMatcherFactory getRequestMatcherFactory() { + return MethodPathRequestMatcherFactory + .fromApplicationContext(getBuilder().getSharedObject(ApplicationContext.class)); + } + private C getBeanOrNull(Class clazz) { if (this.context == null) { return null; diff --git a/config/src/main/java/org/springframework/security/config/web/PathPatternRequestMatcherBuilderFactoryBean.java b/config/src/main/java/org/springframework/security/config/web/PathPatternRequestMatcherBuilderFactoryBean.java new file mode 100644 index 00000000000..93baaebd4aa --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/PathPatternRequestMatcherBuilderFactoryBean.java @@ -0,0 +1,92 @@ +/* + * 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.config.web; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Use this factory bean to configure the {@link PathPatternRequestMatcher.Builder} bean + * used to create request matchers in {@link AuthorizeHttpRequestsConfigurer} and other + * parts of the DSL. + * + * @author Josh Cummings + * @since 6.5 + */ +public final class PathPatternRequestMatcherBuilderFactoryBean + implements FactoryBean, ApplicationContextAware { + + static final String PATTERN_PARSER_BEAN_NAME = "mvcPatternParser"; + + private final PathPatternParser parser; + + private ApplicationContext context; + + /** + * Construct this factory bean using the default {@link PathPatternParser} + * + *

+ * If you are using Spring MVC, it will use the Spring MVC instance. + */ + public PathPatternRequestMatcherBuilderFactoryBean() { + this(null); + } + + /** + * Construct this factory bean using this {@link PathPatternParser}. + * + *

+ * If you are using Spring MVC, it is likely incorrect to call this constructor. + * Please call the default constructor instead. + * @param parser the {@link PathPatternParser} to use + */ + public PathPatternRequestMatcherBuilderFactoryBean(PathPatternParser parser) { + this.parser = parser; + } + + @Override + public PathPatternRequestMatcher.Builder getObject() throws Exception { + if (!this.context.containsBean(PATTERN_PARSER_BEAN_NAME)) { + PathPatternParser parser = (this.parser != null) ? this.parser : PathPatternParser.defaultInstance; + return PathPatternRequestMatcher.withPathPatternParser(parser); + } + PathPatternParser mvc = this.context.getBean(PATTERN_PARSER_BEAN_NAME, PathPatternParser.class); + PathPatternParser parser = (this.parser != null) ? this.parser : mvc; + if (mvc.equals(parser)) { + return PathPatternRequestMatcher.withPathPatternParser(parser); + } + throw new IllegalArgumentException("Spring Security and Spring MVC must use the same path pattern parser. " + + "To have Spring Security use Spring MVC's simply publish this bean [" + + this.getClass().getSimpleName() + "] using its default constructor"); + } + + @Override + public Class getObjectType() { + return PathPatternRequestMatcher.Builder.class; + } + + @Override + public void setApplicationContext(ApplicationContext context) throws BeansException { + this.context = context; + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java index 70f383c203a..ae49e33f521 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java @@ -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; @@ -38,6 +39,7 @@ import org.springframework.security.web.servlet.MockServletContext; import org.springframework.security.web.servlet.TestMockHttpServletMappings; 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.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher; import org.springframework.security.web.util.matcher.RegexRequestMatcher; @@ -86,6 +88,13 @@ public void setUp() { ObjectProvider> given = this.context.getBeanProvider(type); given(given).willReturn(postProcessors); given(postProcessors.getObject()).willReturn(NO_OP_OBJECT_POST_PROCESSOR); + ObjectProvider requestMatcherFactory = new ObjectProvider<>() { + @Override + public PathPatternRequestMatcher.Builder getIfUnique() throws BeansException { + return null; + } + }; + given(this.context.getBeanProvider(PathPatternRequestMatcher.Builder.class)).willReturn(requestMatcherFactory); given(this.context.getServletContext()).willReturn(MockServletContext.mvc()); this.matcherRegistry.setApplicationContext(this.context); mockMvcIntrospector(true); diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java index 41850d67561..a299265d483 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java @@ -51,6 +51,7 @@ import org.springframework.security.config.observation.SecurityObservationSettings; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -64,6 +65,7 @@ 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.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.RequestPostProcessor; @@ -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; @@ -667,6 +670,26 @@ 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()); + } + + @Test + public void requestMatchersWhenFactoryBeanThenAuthorizes() throws Exception { + this.spring.register(PathPatternFactoryBeanConfig.class).autowire(); + this.mvc.perform(get("/path/resource")).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/path/resource").with(user("user").roles("USER"))).andExpect(status().isNotFound()); + } + @Configuration @EnableWebSecurity static class GrantedAuthorityDefaultHasRoleConfig { @@ -1262,6 +1285,10 @@ void rootGet() { void rootPost() { } + @GetMapping("/path") + void path() { + } + } @Configuration @@ -1317,4 +1344,50 @@ SecurityObservationSettings observabilityDefaults() { } + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class MvcRequestMatcherBuilderConfig { + + @Bean + SecurityFilterChain security(HttpSecurity http) throws Exception { + PathPatternRequestMatcher.Builder mvc = PathPatternRequestMatcher.servletPath("/mvc"); + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers(mvc.matcher("/path/**")).hasRole("USER") + ) + .httpBasic(withDefaults()); + // @formatter:on + + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class PathPatternFactoryBeanConfig { + + @Bean + SecurityFilterChain security(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/path/**").hasRole("USER") + ) + .httpBasic(withDefaults()); + // @formatter:on + + return http.build(); + } + + @Bean + PathPatternRequestMatcherBuilderFactoryBean pathPatternFactoryBean() { + return new PathPatternRequestMatcherBuilderFactoryBean(); + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java index f22e55043d9..e6a597e0a8a 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java @@ -34,6 +34,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.userdetails.User; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.test.web.servlet.RequestCacheResultMatcher; @@ -291,6 +292,22 @@ public void getWhenCustomRequestCacheInLambdaThenCustomRequestCacheUsed() throws this.mvc.perform(formLogin(session)).andExpect(redirectedUrl("/")); } + @Test + public void getWhenPathPatternFactoryBeanThenFaviconIcoRedirectsToRoot() throws Exception { + this.spring + .register(RequestCacheDefaultsConfig.class, DefaultSecurityConfig.class, PathPatternFactoryBeanConfig.class) + .autowire(); + // @formatter:off + MockHttpSession session = (MockHttpSession) this.mvc.perform(get("/favicon.ico")) + .andExpect(redirectedUrl("http://localhost/login")) + .andReturn() + .getRequest() + .getSession(); + // @formatter:on + // ignores favicon.ico + this.mvc.perform(formLogin(session)).andExpect(redirectedUrl("/")); + } + private static RequestBuilder formLogin(MockHttpSession session) { // @formatter:off return post("/login") @@ -470,4 +487,15 @@ InMemoryUserDetailsManager userDetailsManager() { } + @Configuration + @EnableWebSecurity + static class PathPatternFactoryBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean factoryBean() { + return new PathPatternRequestMatcherBuilderFactoryBean(); + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/web/PathPatternRequestMatcherBuilderFactoryBeanTests.java b/config/src/test/java/org/springframework/security/config/web/PathPatternRequestMatcherBuilderFactoryBeanTests.java new file mode 100644 index 00000000000..67d16bfa48c --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/PathPatternRequestMatcherBuilderFactoryBeanTests.java @@ -0,0 +1,99 @@ +/* + * 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.config.web; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.web.util.pattern.PathPatternParser; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PathPatternRequestMatcherBuilderFactoryBeanTests { + + GenericApplicationContext context; + + @BeforeEach + void setUp() { + this.context = new GenericApplicationContext(); + } + + @Test + void getObjectWhenDefaultsThenBuilder() throws Exception { + factoryBean().getObject(); + } + + @Test + void getObjectWhenMvcPatternParserThenUses() throws Exception { + PathPatternParser mvc = registerMvcPatternParser(); + PathPatternRequestMatcher.Builder builder = factoryBean().getObject(); + builder.matcher("/path/**"); + verify(mvc).parse("/path/**"); + } + + @Test + void getObjectWhenPathPatternParserThenUses() throws Exception { + PathPatternParser parser = mock(PathPatternParser.class); + PathPatternRequestMatcher.Builder builder = factoryBean(parser).getObject(); + builder.matcher("/path/**"); + verify(parser).parse("/path/**"); + } + + @Test + void getObjectWhenMvcAndPathPatternParserConflictThenIllegalArgument() { + registerMvcPatternParser(); + PathPatternParser parser = mock(PathPatternParser.class); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> factoryBean(parser).getObject()); + } + + @Test + void getObjectWhenMvcAndPathPatternParserAgreeThenUses() throws Exception { + PathPatternParser mvc = registerMvcPatternParser(); + PathPatternRequestMatcher.Builder builder = factoryBean(mvc).getObject(); + builder.matcher("/path/**"); + verify(mvc).parse("/path/**"); + } + + PathPatternRequestMatcherBuilderFactoryBean factoryBean() { + PathPatternRequestMatcherBuilderFactoryBean factoryBean = new PathPatternRequestMatcherBuilderFactoryBean(); + factoryBean.setApplicationContext(this.context); + return factoryBean; + } + + PathPatternRequestMatcherBuilderFactoryBean factoryBean(PathPatternParser parser) { + PathPatternRequestMatcherBuilderFactoryBean factoryBean = new PathPatternRequestMatcherBuilderFactoryBean( + parser); + factoryBean.setApplicationContext(this.context); + return factoryBean; + } + + PathPatternParser registerMvcPatternParser() { + PathPatternParser mvc = mock(PathPatternParser.class); + this.context.registerBean(PathPatternRequestMatcherBuilderFactoryBean.PATTERN_PARSER_BEAN_NAME, + PathPatternParser.class, () -> mvc); + this.context.refresh(); + return mvc; + } + +} diff --git a/docs/modules/ROOT/pages/migration/web.adoc b/docs/modules/ROOT/pages/migration/web.adoc new file mode 100644 index 00000000000..23716dbf6c5 --- /dev/null +++ b/docs/modules/ROOT/pages/migration/web.adoc @@ -0,0 +1,92 @@ += Web Migrations + +[[use-path-pattern]] +== Use PathPatternRequestMatcher by Default + +In Spring Security 7, `AntPathRequestMatcher` and `MvcRequestMatcher` are no longer supported and the Java DSL requires that all URIs be absolute (less any context root). +At that time, Spring Security 7 will use `PathPatternRequestMatcher` by default. + +To check how prepared you are for this change, you can publish this bean: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + return new PathPatternRequestMatcherBuilderFactoryBean(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun requestMatcherBuilder(): PathPatternRequestMatcherBuilderFactoryBean { + return PathPatternRequestMatcherBuilderFactoryBean() +} +---- +====== + +This will tell the Spring Security DSL to use `PathPatternRequestMatcher` for all request matchers that it constructs. + +In the event that you are directly constructing an object (as opposed to having the DSL construct it) that has a `setRequestMatcher` method. you should also proactively specify a `PathPatternRequestMatcher` there as well. + +For example, in the case of `LogoutFilter`, it constructs an `AntPathRequestMatcher` in Spring Security 6: + +[method,java] +---- +private RequestMatcher logoutUrl = new AntPathRequestMatcher("/logout"); +---- + +and will change this to a `PathPatternRequestMatcher` in 7: + +[method,java] +---- +private RequestMatcher logoutUrl = PathPatternRequestMatcher.path().matcher("/logout"); +---- + +If you are constructing your own `LogoutFilter`, consider calling `setLogoutRequestMatcher` to provide this `PathPatternRequestMatcher` in advance. + +== Include the Servlet Path Prefix in Authorization Rules + +For many applications <> will make no difference since most commonly all URIs listed are matched by the default servlet. + +However, if you have other servlets with servlet path prefixes, xref:servlet/authorization/authorize-http-requests.adoc[then these paths now need to be supplied separately]. + +For example, if I have a Spring MVC controller with `@RequestMapping("/orders")` and my MVC application is deployed to `/mvc` (instead of the default servlet), then the URI for this endpoint is `/mvc/orders`. +Historically, the Java DSL hasn't had a simple way to specify the servlet path prefix and Spring Security attempted to infer it. + +Over time, we learned that these inference would surprise developers. +Instead of taking this responsibility away from developers, now it is simpler to specify the servlet path prefix like so: + +[method,java] +---- +PathPatternRequestParser.Builder servlet = PathPatternRequestParser.servletPath("/mvc"); +http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers(servlet.pattern("/orders/**").matcher()).authenticated() + ) +---- + + +For paths that belong to the default servlet, use `PathPatternRequestParser.path()` instead: + +[method,java] +---- +PathPatternRequestParser.Builder request = PathPatternRequestParser.path(); +http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers(request.pattern("/js/**").matcher()).authenticated() + ) +---- + +Note that this doesn't address every kind of servlet since not all servlets have a path prefix. +For example, expressions that match the JSP Servlet might use an ant pattern `/**/*.jsp`. + +There is not yet a general-purpose replacement for these, and so you are encouraged to use `RegexRequestMatcher`, like so: `regexMatcher("\\.jsp$")`. + +For many applications this will make no difference since most commonly all URIs listed are matched by the default servlet. 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 4eaf5f3d5ee..fcef19ad009 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc @@ -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] @@ -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.PathPatternRequestMatcher.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/**").matcher()).hasAuthority("admin") + .requestMatchers(servletPath("/spring-mvc").pattern("/my/controller/**").matcher()).hasAuthority("controller") .anyRequest().authenticated() ); @@ -616,17 +610,15 @@ 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:: @@ -634,16 +626,39 @@ Xml:: [source,xml,role="secondary"] ---- + ---- ====== -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.PathPatternRequestMatcher.servletPath; + +@Bean +SecurityFilterChain appEndpoints(HttpSecurity http) { + PathPatternRequestMatcher.Builder mvc = servletPath("/spring-mvc"); + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers(mvc.pattern("/admin/**").matcher()).hasAuthority("admin") + .requestMatchers(mvc.pattern("/my/controller/**").matcher()).hasAuthority("controller") + .anyRequest().authenticated() + ); -* 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) + return http.build(); +} +---- + +[TIP] +===== +There are several other components that create request matchers for you like {spring-boot-api-url}org/springframework/boot/autoconfigure/security/servlet/PathRequest.html[`PathRequest#toStaticResources#atCommonLocations`] +===== [[match-by-custom]] === Using a Custom Matcher diff --git a/web/src/main/java/org/springframework/security/web/FilterChainProxy.java b/web/src/main/java/org/springframework/security/web/FilterChainProxy.java index 7795f2c8bac..f9ad696436b 100644 --- a/web/src/main/java/org/springframework/security/web/FilterChainProxy.java +++ b/web/src/main/java/org/springframework/security/web/FilterChainProxy.java @@ -46,6 +46,7 @@ import org.springframework.util.Assert; import org.springframework.web.filter.DelegatingFilterProxy; import org.springframework.web.filter.GenericFilterBean; +import org.springframework.web.filter.ServletRequestPathFilter; /** * Delegates {@code Filter} requests to a list of Spring-managed filter beans. As of @@ -162,6 +163,8 @@ public class FilterChainProxy extends GenericFilterBean { private FilterChainDecorator filterChainDecorator = new VirtualFilterChainDecorator(); + private Filter springWebFilter = new ServletRequestPathFilter(); + public FilterChainProxy() { } @@ -210,27 +213,29 @@ private void doFilterInternal(ServletRequest request, ServletResponse response, throws IOException, ServletException { FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request); HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response); - List filters = getFilters(firewallRequest); - if (filters == null || filters.isEmpty()) { - if (logger.isTraceEnabled()) { - logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest))); + this.springWebFilter.doFilter(firewallRequest, firewallResponse, (r, s) -> { + List filters = getFilters(firewallRequest); + if (filters == null || filters.isEmpty()) { + if (logger.isTraceEnabled()) { + logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest))); + } + firewallRequest.reset(); + this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse); + return; } - firewallRequest.reset(); - this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse); - return; - } - if (logger.isDebugEnabled()) { - logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest))); - } - FilterChain reset = (req, res) -> { if (logger.isDebugEnabled()) { - logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest))); + logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest))); } - // Deactivate path stripping as we exit the security filter chain - firewallRequest.reset(); - chain.doFilter(req, res); - }; - this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse); + FilterChain reset = (req, res) -> { + if (logger.isDebugEnabled()) { + logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest))); + } + // Deactivate path stripping as we exit the security filter chain + firewallRequest.reset(); + chain.doFilter(req, res); + }; + this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse); + }); } /** @@ -447,4 +452,23 @@ public FilterChain decorate(FilterChain original, List filters) { } + private static final class FirewallFilter implements Filter { + + private final HttpFirewall firewall; + + private FirewallFilter(HttpFirewall firewall) { + this.firewall = firewall; + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + filterChain.doFilter(this.firewall.getFirewalledRequest(request), + this.firewall.getFirewalledResponse(response)); + } + + } + } 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 00000000000..40a5ce5b038 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java @@ -0,0 +1,393 @@ +/* + * 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.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +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.lang.Nullable; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +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}. The provided path should be relative to the servlet (that + * is, it should exclude any context or servlet path). + * + *

+ * To also match the servlet, please see {@link PathPatternRequestMatcher#servletPath} + * + *

+ * Note that the {@link org.springframework.web.servlet.HandlerMapping} that contains the + * related URI patterns must be using {@link PathPatternParser#defaultInstance}. If that + * is not the case, use {@link PathPatternParser} to parse your path and provide a + * {@link PathPattern} in the constructor. + *

+ * + * @author Josh Cummings + * @since 6.5 + */ +public final class PathPatternRequestMatcher implements RequestMatcher { + + private final PathPattern pattern; + + private RequestMatcher servletPath = AnyRequestMatcher.INSTANCE; + + private RequestMatcher method = AnyRequestMatcher.INSTANCE; + + /** + * Creates a {@link PathPatternRequestMatcher} that uses the provided {@code pattern}. + *

+ * The {@code pattern} should be relative to the servlet path + *

+ * @param pattern the pattern used to match + */ + private PathPatternRequestMatcher(PathPattern pattern) { + this.pattern = pattern; + } + + /** + * Create a {@link PathPatternRequestMatcher} whose URIs do not have a servlet path + * prefix + *

+ * When there is no context path, then these URIs are effectively absolute. + * @return a {@link Builder} that treats URIs as relative to the context path, if any + */ + public static Builder path() { + return new Builder(); + } + + /** + * Create a {@link PathPatternRequestMatcher} whose URIs are relative to the given + * {@code servletPath} prefix. + * + *

+ * The {@code servletPath} must correlate to a value that would match the result of + * {@link HttpServletRequest#getServletPath()} and its corresponding servlet. + * + *

+ * That is, if you have a servlet mapping of {@code /path/*}, then + * {@link HttpServletRequest#getServletPath()} would return {@code /path} and so + * {@code /path} is what is specified here. + * + *

+ * Specify the path here without the trailing {@code /*}. + * @return a {@link Builder} that treats URIs as relative to the given + * {@code servletPath} + */ + public static Builder servletPath(String servletPath) { + return new Builder().servletPath(servletPath); + } + + /** + * Use this {@link PathPatternParser} to parse path patterns. Uses + * {@link PathPatternParser#defaultInstance} by default. + * @param parser the {@link PathPatternParser} to use + * @return a {@link Builder} that treats URIs as relative to the given + * {@code servletPath} + */ + public static Builder withPathPatternParser(PathPatternParser parser) { + Assert.notNull(parser, "pathPatternParser cannot be null"); + return new Builder(parser); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean matches(HttpServletRequest request) { + return matcher(request).isMatch(); + } + + /** + * {@inheritDoc} + */ + @Override + public MatchResult matcher(HttpServletRequest request) { + if (!this.servletPath.matches(request)) { + return MatchResult.notMatch(); + } + if (!this.method.matches(request)) { + return MatchResult.notMatch(); + } + PathContainer path = getRequestPath(request).pathWithinApplication(); + PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(path); + return (info != null) ? MatchResult.match(info.getUriVariables()) : MatchResult.notMatch(); + } + + void setMethod(RequestMatcher method) { + this.method = method; + } + + void setServletPath(RequestMatcher servletPath) { + this.servletPath = servletPath; + } + + private RequestPath getRequestPath(HttpServletRequest request) { + return ServletRequestPathUtils.getParsedRequestPath(request); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (!(o instanceof PathPatternRequestMatcher that)) { + return false; + } + return Objects.equals(this.pattern, that.pattern); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hash(this.pattern); + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + StringBuilder request = new StringBuilder(); + if (this.method instanceof HttpMethodRequestMatcher m) { + request.append(m.method.name()).append(' '); + } + if (this.servletPath instanceof ServletPathRequestMatcher s) { + request.append(s.path); + } + return "PathPattern [" + request + this.pattern + "]"; + } + + /** + * A builder for specifying various elements of a request for the purpose of creating + * a {@link PathPatternRequestMatcher}. + * + *

+ * For example, if Spring MVC is deployed to `/mvc` and another servlet to `/other`, + * then you can use this builder to do: + *

+ * + * + * http + * .authorizeHttpRequests((authorize) -> authorize + * .requestMatchers(servletPath("/mvc").matcher("/user/**")).hasAuthority("user") + * .requestMatchers(servletPath("/other").matcher("/admin/**")).hasAuthority("admin") + * ) + * ... + * + */ + public static final class Builder { + + private final PathPatternParser parser; + + private RequestMatcher servletPath = AnyRequestMatcher.INSTANCE; + + private Builder() { + this.parser = PathPatternParser.defaultInstance; + } + + private Builder(PathPatternParser parser) { + this.parser = parser; + } + + /** + * Match requests starting with this {@code servletPath}. + * @param servletPath the servlet path prefix + * @return the {@link Builder} for more configuration + * @see PathPatternRequestMatcher#servletPath + */ + public Builder servletPath(String servletPath) { + this.servletPath = new ServletPathRequestMatcher(servletPath); + return this; + } + + /** + * Match requests having this path pattern. + * + *

+ * When the HTTP {@code method} is null, then the matcher does not consider the + * HTTP method + * + *

+ * Path patterns always start with a slash and may contain placeholders. They can + * also be followed by {@code /**} to signify all URIs under a given path. + * + *

+ * These must be specified relative to any servlet path prefix (meaning you should + * exclude the context path and any servlet path prefix in stating your pattern). + * + *

+ * The following are valid patterns and their meaning + *

    + *
  • {@code /path} - match exactly and only `/path`
  • + *
  • {@code /path/**} - match `/path` and any of its descendents
  • + *
  • {@code /path/{value}/**} - match `/path/subdirectory` and any of its + * descendents, capturing the value of the subdirectory in + * {@link RequestAuthorizationContext#getVariables()}
  • + *
+ * + *

+ * A more comprehensive list can be found at {@link PathPattern}. + * @param path the path pattern to match + * @return the {@link Builder} for more configuration + */ + public PathPatternRequestMatcher matcher(String path) { + return matcher(null, path); + } + + /** + * Match requests having this {@link HttpMethod} and path pattern. + * + *

+ * When the HTTP {@code method} is null, then the matcher does not consider the + * HTTP method + * + *

+ * Path patterns always start with a slash and may contain placeholders. They can + * also be followed by {@code /**} to signify all URIs under a given path. + * + *

+ * These must be specified relative to any servlet path prefix (meaning you should + * exclude the context path and any servlet path prefix in stating your pattern). + * + *

+ * The following are valid patterns and their meaning + *

    + *
  • {@code /path} - match exactly and only `/path`
  • + *
  • {@code /path/**} - match `/path` and any of its descendents
  • + *
  • {@code /path/{value}/**} - match `/path/subdirectory` and any of its + * descendents, capturing the value of the subdirectory in + * {@link RequestAuthorizationContext#getVariables()}
  • + *
+ * + *

+ * A more comprehensive list can be found at {@link PathPattern}. + * @param method the {@link HttpMethod} to match, may be null + * @param path the path pattern to match + * @return the {@link Builder} for more configuration + */ + public PathPatternRequestMatcher matcher(@Nullable HttpMethod method, String path) { + Assert.notNull(path, "pattern cannot be null"); + Assert.isTrue(path.startsWith("/"), "pattern must start with a /"); + PathPattern pathPattern = this.parser.parse(path); + PathPatternRequestMatcher requestMatcher = new PathPatternRequestMatcher(pathPattern); + if (method != null) { + requestMatcher.setMethod(new HttpMethodRequestMatcher(method)); + } + if (this.servletPath != AnyRequestMatcher.INSTANCE) { + requestMatcher.setServletPath(this.servletPath); + } + return requestMatcher; + } + + } + + private static final class HttpMethodRequestMatcher implements RequestMatcher { + + private final HttpMethod method; + + HttpMethodRequestMatcher(HttpMethod method) { + this.method = method; + } + + @Override + public boolean matches(HttpServletRequest request) { + return this.method.name().equals(request.getMethod()); + } + + @Override + public String toString() { + return "HttpMethod [" + this.method + "]"; + } + + } + + private static final class ServletPathRequestMatcher implements RequestMatcher { + + private final String path; + + private final AtomicReference servletExists = new AtomicReference<>(); + + ServletPathRequestMatcher(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"); + this.path = servletPath; + } + + @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, ServletRequestPathUtils.getServletPathPrefix(request)); + } + + private boolean servletExists(HttpServletRequest request) { + return this.servletExists.updateAndGet((value) -> { + if (value != null) { + return value; + } + if (request.getAttribute("org.springframework.test.web.servlet.MockMvc.MVC_RESULT_ATTRIBUTE") != null) { + return true; + } + for (ServletRegistration registration : request.getServletContext() + .getServletRegistrations() + .values()) { + if (registration.getMappings().contains(this.path + "/*")) { + return true; + } + } + return false; + }); + } + + private Map> registrationMappings(HttpServletRequest request) { + Map> map = new LinkedHashMap<>(); + ServletContext servletContext = request.getServletContext(); + for (ServletRegistration registration : servletContext.getServletRegistrations().values()) { + map.put(registration.getName(), registration.getMappings()); + } + return map; + } + + @Override + public String toString() { + return "ServletPath [" + this.path + "]"; + } + + } + +} 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 e3049b83742..9f3ad5d5672 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 @@ -100,9 +100,8 @@ public static AntPathRequestMatcher antMatcher(HttpMethod method) { * @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()); + return new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null); } /** diff --git a/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java b/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java index 778efc545fc..2e8f7a552a6 100644 --- a/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java +++ b/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java @@ -48,6 +48,7 @@ import org.springframework.security.web.firewall.HttpFirewall; import org.springframework.security.web.firewall.RequestRejectedException; import org.springframework.security.web.firewall.RequestRejectedHandler; +import org.springframework.security.web.servlet.TestMockHttpServletMappings; import org.springframework.security.web.util.matcher.RequestMatcher; import static org.assertj.core.api.Assertions.assertThat; @@ -166,6 +167,7 @@ public void wrapperIsResetWhenNoMatchingFilters() throws Exception { FirewalledRequest fwr = mock(FirewalledRequest.class); given(fwr.getRequestURI()).willReturn("/"); given(fwr.getContextPath()).willReturn(""); + given(fwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping()); this.fcp.setFirewall(fw); given(fw.getFirewalledRequest(this.request)).willReturn(fwr); given(this.matcher.matches(any(HttpServletRequest.class))).willReturn(false); @@ -183,9 +185,11 @@ public void bothWrappersAreResetWithNestedFcps() throws Exception { FirewalledRequest firstFwr = mock(FirewalledRequest.class, "firstFwr"); given(firstFwr.getRequestURI()).willReturn("/"); given(firstFwr.getContextPath()).willReturn(""); + given(firstFwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping()); FirewalledRequest fwr = mock(FirewalledRequest.class, "fwr"); given(fwr.getRequestURI()).willReturn("/"); given(fwr.getContextPath()).willReturn(""); + given(fwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping()); given(fw.getFirewalledRequest(this.request)).willReturn(firstFwr); given(fw.getFirewalledRequest(firstFwr)).willReturn(fwr); given(fwr.getRequest()).willReturn(firstFwr); 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 00000000000..8cb592fcc5f --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java @@ -0,0 +1,154 @@ +/* + * 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.Servlet; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.servlet.MockServletContext; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.util.ServletRequestPathUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * Tests for {@link PathPatternRequestMatcher} + */ +public class PathPatternRequestMatcherTests { + + @Test + void matcherWhenPatternMatchesRequestThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher("/uri"); + assertThat(matcher.matches(request("/uri"))).isTrue(); + } + + @Test + void matcherWhenPatternContainsPlaceholdersThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher("/uri/{username}"); + assertThat(matcher.matcher(request("/uri/bob")).getVariables()).containsEntry("username", "bob"); + } + + @Test + void matcherWhenOnlyPathInfoMatchesThenMatches() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher("/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isTrue(); + } + + @Test + void matcherWhenUriContainsServletPathThenNoMatch() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher("/mvc/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isFalse(); + } + + @Test + void matcherWhenSameMethodThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("/uri"))).isTrue(); + } + + @Test + void matcherWhenDifferentPathThenNoMatch() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/urj", ""))).isFalse(); + } + + @Test + void matcherWhenDifferentMethodThenNoMatch() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("POST", "/mvc/uri", "/mvc"))).isFalse(); + } + + @Test + void matcherWhenNoMethodThenMatches() { + RequestMatcher matcher = PathPatternRequestMatcher.path().matcher("/uri"); + assertThat(matcher.matches(request("POST", "/uri", ""))).isTrue(); + assertThat(matcher.matches(request("GET", "/uri", ""))).isTrue(); + } + + @Test + void matcherWhenServletPathThenMatchesOnlyServletPath() { + PathPatternRequestMatcher.Builder servlet = PathPatternRequestMatcher.servletPath("/servlet/path"); + RequestMatcher matcher = servlet.matcher(HttpMethod.GET, "/endpoint"); + ServletContext servletContext = servletContext("/servlet/path"); + MockHttpServletRequest mock = get("/servlet/path/endpoint").servletPath("/servlet/path") + .buildRequest(servletContext); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isTrue(); + mock = get("/endpoint").servletPath("/endpoint").buildRequest(servletContext); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isFalse(); + } + + @Test + void matcherWhenRequestPathThenIgnoresServletPath() { + PathPatternRequestMatcher.Builder request = PathPatternRequestMatcher.path(); + RequestMatcher matcher = request.matcher(HttpMethod.GET, "/endpoint"); + MockHttpServletRequest mock = get("/servlet/path/endpoint").servletPath("/servlet/path").buildRequest(null); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isTrue(); + mock = get("/endpoint").servletPath("/endpoint").buildRequest(null); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isTrue(); + } + + @Test + void matcherWhenServletPathThenRequiresServletPathToExist() { + PathPatternRequestMatcher.Builder servlet = PathPatternRequestMatcher.servletPath("/servlet/path"); + RequestMatcher matcher = servlet.matcher(HttpMethod.GET, "/endpoint"); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy( + () -> matcher.matches(get("/servlet/path/endpoint").servletPath("/servlet/path").buildRequest(null))); + } + + @Test + void servletPathWhenEndsWithSlashOrStarThenIllegalArgument() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> PathPatternRequestMatcher.servletPath("/path/**")); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> PathPatternRequestMatcher.servletPath("/path/*")); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> PathPatternRequestMatcher.servletPath("/path/")); + } + + MockHttpServletRequest request(String uri) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", uri); + ServletRequestPathUtils.parseAndCache(request); + return request; + } + + MockHttpServletRequest request(String method, String uri, String servletPath) { + MockHttpServletRequest request = new MockHttpServletRequest(method, uri); + request.setServletPath(servletPath); + ServletRequestPathUtils.parseAndCache(request); + return request; + } + + MockServletContext servletContext(String... servletPath) { + MockServletContext servletContext = new MockServletContext(); + ServletRegistration.Dynamic registration = servletContext.addServlet("servlet", Servlet.class); + for (String s : servletPath) { + registration.addMapping(s + "/*"); + } + return servletContext; + } + +}