From 762319b6bea04027ee83208f9e66073ab081bc40 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 11 Oct 2023 14:18:11 -0600 Subject: [PATCH] Add forServletPattern Closes gh-13562 --- ...AbstractRequestMatcherBuilderRegistry.java | 52 +++ .../AntPathRequestMatcherBuilder.java | 59 +++ .../AuthorizeHttpRequestsConfigurer.java | 274 +++++++++++-- ...ervletDelegatingRequestMatcherBuilder.java | 103 +++++ .../configurers/MvcRequestMatcherBuilder.java | 76 ++++ .../configurers/RequestMatcherBuilder.java | 106 +++++ .../configurers/RequestMatcherBuilders.java | 215 ++++++++++ .../ServletPatternRequestMatcher.java | 43 ++ .../ServletRegistrationCollection.java | 152 ++++++++ ...tractRequestMatcherRegistryNoMvcTests.java | 7 + .../AbstractRequestMatcherRegistryTests.java | 6 - ...actRequestMatcherBuilderRegistryTests.java | 349 +++++++++++++++++ .../AuthorizeHttpRequestsConfigurerTests.java | 367 +++++++++++++++++- .../RequestMatcherBuildersTests.java | 198 ++++++++++ .../ServletPatternRequestMatcherTests.java | 63 +++ .../TestMockHttpServletMappings.java | 46 +++ .../authorize-http-requests.adoc | 162 ++++++-- 17 files changed, 2196 insertions(+), 82 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistry.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/AntPathRequestMatcherBuilder.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/DispatcherServletDelegatingRequestMatcherBuilder.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/MvcRequestMatcherBuilder.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilder.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilders.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcher.java create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletRegistrationCollection.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistryTests.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuildersTests.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcherTests.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/TestMockHttpServletMappings.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistry.java new file mode 100644 index 0000000000..57bd420c3b --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistry.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2023 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.config.annotation.web.AbstractRequestMatcherRegistry; +import org.springframework.security.web.util.matcher.RequestMatcher; + +abstract class AbstractRequestMatcherBuilderRegistry extends AbstractRequestMatcherRegistry { + + private final RequestMatcherBuilder builder; + + AbstractRequestMatcherBuilderRegistry(ApplicationContext context) { + this(context, RequestMatcherBuilders.createDefault(context)); + } + + AbstractRequestMatcherBuilderRegistry(ApplicationContext context, RequestMatcherBuilder builder) { + setApplicationContext(context); + this.builder = builder; + } + + @Override + public final C requestMatchers(String... patterns) { + return requestMatchers(null, patterns); + } + + @Override + public final C requestMatchers(HttpMethod method, String... patterns) { + return requestMatchers(this.builder.matchers(method, patterns).toArray(RequestMatcher[]::new)); + } + + @Override + public final C requestMatchers(HttpMethod method) { + return requestMatchers(method, "/**"); + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AntPathRequestMatcherBuilder.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AntPathRequestMatcherBuilder.java new file mode 100644 index 0000000000..4026fa2d2f --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AntPathRequestMatcherBuilder.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2023 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.http.HttpMethod; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +final class AntPathRequestMatcherBuilder implements RequestMatcherBuilder { + + private final String servletPath; + + private AntPathRequestMatcherBuilder(String servletPath) { + this.servletPath = servletPath; + } + + static AntPathRequestMatcherBuilder absolute() { + return new AntPathRequestMatcherBuilder(null); + } + + static AntPathRequestMatcherBuilder relativeTo(String path) { + return new AntPathRequestMatcherBuilder(path); + } + + @Override + public AntPathRequestMatcher matcher(String pattern) { + return matcher((String) null, pattern); + } + + @Override + public AntPathRequestMatcher matcher(HttpMethod method, String pattern) { + return matcher((method != null) ? method.name() : null, pattern); + } + + private AntPathRequestMatcher matcher(String method, String pattern) { + return new AntPathRequestMatcher(prependServletPath(pattern), method); + } + + private String prependServletPath(String pattern) { + if (this.servletPath == null) { + return pattern; + } + return this.servletPath + pattern; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java index 1de4750a49..d1c9e1f7f4 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java @@ -16,10 +16,14 @@ package org.springframework.security.config.annotation.web.configurers; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Function; import java.util.function.Supplier; import io.micrometer.observation.ObservationRegistry; +import jakarta.servlet.http.HttpServletMapping; import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.ApplicationContext; @@ -32,17 +36,22 @@ import org.springframework.security.authorization.AuthorizationEventPublisher; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.authorization.ObservationAuthorizationManager; import org.springframework.security.authorization.SpringAuthorizationEventPublisher; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.ObjectPostProcessor; import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.core.GrantedAuthorityDefaults; import org.springframework.security.web.access.intercept.AuthorizationFilter; 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.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcherEntry; import org.springframework.util.Assert; import org.springframework.util.function.SingletonSupplier; +import org.springframework.web.servlet.DispatcherServlet; /** * Adds a URL based authorization using {@link AuthorizationManager}. @@ -137,41 +146,62 @@ public final class AuthorizeHttpRequestsConfigurer { + extends AbstractRequestMatcherBuilderRegistry> { private final RequestMatcherDelegatingAuthorizationManager.Builder managerBuilder = RequestMatcherDelegatingAuthorizationManager .builder(); - private List unmappedMatchers; + List unmappedMatchers; private int mappingCount; private boolean shouldFilterAllDispatcherTypes = true; - private AuthorizationManagerRequestMatcherRegistry(ApplicationContext context) { - setApplicationContext(context); + private final Map servletPattern = new LinkedHashMap<>(); + + AuthorizationManagerRequestMatcherRegistry(ApplicationContext context) { + super(context); } private void addMapping(RequestMatcher matcher, AuthorizationManager manager) { + Assert.isTrue(this.servletPattern.isEmpty(), + "Since you have used forServletPattern, all request matchers must be configured using forServletPattern; alternatively, you can use requestMatchers(RequestMatcher) for all requests."); this.unmappedMatchers = null; this.managerBuilder.add(matcher, manager); this.mappingCount++; } private void addFirst(RequestMatcher matcher, AuthorizationManager manager) { + Assert.isTrue(this.servletPattern.isEmpty(), + "Since you have used forServletPattern, all request matchers must be configured using forServletPattern; alternatively, you can use requestMatchers(RequestMatcher) for all requests."); this.unmappedMatchers = null; this.managerBuilder.mappings((m) -> m.add(0, new RequestMatcherEntry<>(matcher, manager))); this.mappingCount++; } - private AuthorizationManager createAuthorizationManager() { + private AuthorizationManager servletAuthorizationManager() { + for (Map.Entry entry : this.servletPattern + .entrySet()) { + AuthorizationManagerServletRequestMatcherRegistry registry = entry.getValue(); + this.managerBuilder.add(new ServletPatternRequestMatcher(entry.getKey()), + registry.authorizationManager()); + } + return postProcess(this.managerBuilder.build()); + } + + private AuthorizationManager authorizationManager() { Assert.state(this.unmappedMatchers == null, () -> "An incomplete mapping was found for " + this.unmappedMatchers + ". Try completing it with something like requestUrls()..hasRole('USER')"); Assert.state(this.mappingCount > 0, "At least one mapping is required (for example, authorizeHttpRequests().anyRequest().authenticated())"); + return postProcess(this.managerBuilder.build()); + } + + private AuthorizationManager createAuthorizationManager() { + AuthorizationManager manager = (this.servletPattern.isEmpty()) ? authorizationManager() + : servletAuthorizationManager(); ObservationRegistry registry = getObservationRegistry(); - RequestMatcherDelegatingAuthorizationManager manager = postProcess(this.managerBuilder.build()); if (registry.isNoop()) { return manager; } @@ -179,9 +209,77 @@ public final class AuthorizeHttpRequestsConfigurer requestMatchers) { + protected AuthorizedUrl chainRequestMatchers( + List requestMatchers) { this.unmappedMatchers = requestMatchers; - return new AuthorizedUrl(requestMatchers); + return new AuthorizedUrl<>( + (manager) -> AuthorizeHttpRequestsConfigurer.this.addMapping(requestMatchers, manager)); + } + + /** + * Begin registering {@link RequestMatcher}s based on the type of the servlet + * mapped to {@code pattern}. Each registered request matcher will additionally + * check {@link HttpServletMapping#getPattern} against the provided + * {@code pattern}. + * + *

+ * If the corresponding servlet is of type {@link DispatcherServlet}, then use a + * {@link AuthorizationManagerServletRequestMatcherRegistry} that registers + * {@link MvcRequestMatcher}s. + * + *

+ * Otherwise, use a configurer that registers {@link AntPathRequestMatcher}s. + * + *

+ * When doing a path-based pattern, like `/path/*`, registered URIs should leave + * out the matching path. For example, if the target URI is `/path/resource/3`, + * then the configuration should look like this: + * .forServletPattern("/path/*", (path) -> path + * .requestMatchers("/resource/3").hasAuthority(...) + * ) + * + * + *

+ * Or, if the pattern is `/path/subpath/*`, and the URI is + * `/path/subpath/resource/3`, then the configuration should look like this: + * + * .forServletPattern("/path/subpath/*", (path) -> path + * .requestMatchers("/resource/3").hasAuthority(...) + * ) + * + * + *

+ * For all other patterns, please supply the URI in absolute terms. For example, + * if the target URI is `/js/**` and it matches to the default servlet, then the + * configuration should look like this: + * .forServletPattern("/", (root) -> root + * .requestMatchers("/js/**").hasAuthority(...) + * ) + * + * + *

+ * Or, if the target URI is `/views/**`, and it matches to a `*.jsp` extension + * servlet, then the configuration should look like this: + * .forServletPattern("*.jsp", (jsp) -> jsp + * .requestMatchers("/views/**").hasAuthority(...) + * ) + * + * @param customizer a customizer that uses a + * {@link AuthorizationManagerServletRequestMatcherRegistry} for URIs mapped to + * the provided servlet + * @return an {@link AuthorizationManagerServletRequestMatcherRegistry} for + * further configurations + * @since 6.2 + */ + public AuthorizationManagerRequestMatcherRegistry forServletPattern(String pattern, + Customizer customizer) { + ApplicationContext context = getApplicationContext(); + RequestMatcherBuilder builder = RequestMatcherBuilders.createForServletPattern(context, pattern); + AuthorizationManagerServletRequestMatcherRegistry registry = new AuthorizationManagerServletRequestMatcherRegistry( + builder); + customizer.customize(registry); + this.servletPattern.put(pattern, registry); + return this; } /** @@ -237,6 +335,125 @@ public final class AuthorizeHttpRequestsConfigurer + * This class is designed primarily for use with the {@link HttpSecurity} DSL. For + * that reason, please use {@link HttpSecurity#authorizeHttpRequests} instead as + * it exposes this class fluently alongside related DSL configurations. + * + *

+ * NOTE: In many cases, which kind of request matcher is needed is apparent by the + * servlet configuration, and so you should generally use the methods found in + * {@link AbstractRequestMatcherRegistry} instead of this these. Use this class + * when you want or need to indicate which request matcher URIs belong to which + * servlet. + * + *

+ * In all cases, though, you may arrange your request matchers by servlet pattern + * with the {@link AuthorizationManagerRequestMatcherRegistry#forServletPattern} + * method in the {@link HttpSecurity#authorizeHttpRequests} DSL. + * + *

+ * Consider, for example, the circumstance where you have Spring MVC configured + * and also Spring Boot H2 Console. Spring MVC registers a servlet of type + * {@link DispatcherServlet} as the default servlet and Spring Boot registers a + * servlet of its own as well at `/h2-console/*`. + * + *

+ * Such might have a configuration like this in Spring Security: + * http + * .authorizeHttpRequests((authorize) -> authorize + * .requestMatchers("/js/**", "/css/**").permitAll() + * .requestMatchers("/my/controller/**").hasAuthority("CONTROLLER") + * .requestMatchers("/h2-console/**").hasAuthority("H2") + * ) + * // ... + * + * + *

+ * Spring Security by default addresses the above configuration on its own. + * + *

+ * However, consider the same situation, but where {@link DispatcherServlet} is + * mapped to a path like `/mvc/*`. In this case, the above configuration is + * ambiguous, and you should use this class to clarify the rest of each MVC URI + * like so: + * http + * .authorizeHttpRequests((authorize) -> authorize + * .forServletPattern("/", (root) -> root + * .requestMatchers("/js/**", "/css/**").permitAll() + * ) + * .forServletPattern("/mvc/*", (mvc) -> mvc + * .requestMatchers("/my/controller/**").hasAuthority("CONTROLLER") + * ) + * .forServletPattern("/h2-console/*", (h2) -> h2 + * .anyRequest().hasAuthority("OTHER") + * ) + * ) + * // ... + * + * + *

+ * In the above configuration, it's now clear to Spring Security that the + * following matchers map to these corresponding URIs: + * + *

    + *
  • <default> + `/js/**` ==> `/js/**`
  • + *
  • <default> + `/css/**` ==> `/css/**`
  • + *
  • `/mvc` + `/my/controller/**` ==> + * `/mvc/my/controller/**`
  • + *
  • `/h2-console` + <any request> ==> + * `/h2-console/**`
  • + *
+ * + * @author Josh Cummings + * @since 6.2 + * @see AbstractRequestMatcherRegistry + * @see AuthorizeHttpRequestsConfigurer + */ + public final class AuthorizationManagerServletRequestMatcherRegistry extends + AbstractRequestMatcherBuilderRegistry> { + + private final RequestMatcherDelegatingAuthorizationManager.Builder managerBuilder = RequestMatcherDelegatingAuthorizationManager + .builder(); + + private List unmappedMatchers; + + AuthorizationManagerServletRequestMatcherRegistry(RequestMatcherBuilder builder) { + super(AuthorizationManagerRequestMatcherRegistry.this.getApplicationContext(), builder); + } + + AuthorizationManager authorizationManager() { + Assert.state(this.unmappedMatchers == null, + () -> "An incomplete mapping was found for " + this.unmappedMatchers + + ". Try completing it with something like requestUrls()..hasRole('USER')"); + AuthorizationManager request = this.managerBuilder.build(); + return (authentication, context) -> request.check(authentication, context.getRequest()); + } + + @Override + protected AuthorizedUrl chainRequestMatchers( + List requestMatchers) { + this.unmappedMatchers = requestMatchers; + return new AuthorizedUrl<>((manager) -> addMapping(requestMatchers, manager)); + } + + private AuthorizationManagerServletRequestMatcherRegistry addMapping(List matchers, + AuthorizationManager manager) { + this.unmappedMatchers = null; + for (RequestMatcher matcher : matchers) { + this.managerBuilder.add(matcher, manager); + } + return this; + } + + } + } /** @@ -245,20 +462,12 @@ public final class AuthorizeHttpRequestsConfigurer { - private final List matchers; + private final Function, R> registrar; - /** - * Creates an instance. - * @param matchers the {@link RequestMatcher} instances to map - */ - AuthorizedUrl(List matchers) { - this.matchers = matchers; - } - - protected List getMatchers() { - return this.matchers; + AuthorizedUrl(Function, R> registrar) { + this.registrar = registrar; } /** @@ -266,7 +475,7 @@ public final class AuthorizeHttpRequestsConfigurer new AuthorizationDecision(false)); } @@ -286,7 +495,7 @@ public final class AuthorizeHttpRequestsConfigurer manager) { + public R access(AuthorizationManager manager) { Assert.notNull(manager, "manager cannot be null"); - return AuthorizeHttpRequestsConfigurer.this.addMapping(this.matchers, manager); + return this.registrar.apply(manager); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/DispatcherServletDelegatingRequestMatcherBuilder.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/DispatcherServletDelegatingRequestMatcherBuilder.java new file mode 100644 index 0000000000..bb300d5703 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/DispatcherServletDelegatingRequestMatcherBuilder.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2023 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 jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +final class DispatcherServletDelegatingRequestMatcherBuilder implements RequestMatcherBuilder { + + final MvcRequestMatcherBuilder mvc; + + final AntPathRequestMatcherBuilder ant; + + final ServletRegistrationCollection registrations; + + DispatcherServletDelegatingRequestMatcherBuilder(MvcRequestMatcherBuilder mvc, AntPathRequestMatcherBuilder ant, + ServletRegistrationCollection registrations) { + this.mvc = mvc; + this.ant = ant; + this.registrations = registrations; + } + + @Override + public RequestMatcher matcher(String pattern) { + MvcRequestMatcher mvc = this.mvc.matcher(pattern); + AntPathRequestMatcher ant = this.ant.matcher(pattern); + return new DispatcherServletDelegatingRequestMatcher(mvc, ant, this.registrations); + } + + @Override + public RequestMatcher matcher(HttpMethod method, String pattern) { + MvcRequestMatcher mvc = this.mvc.matcher(method, pattern); + AntPathRequestMatcher ant = this.ant.matcher(method, pattern); + return new DispatcherServletDelegatingRequestMatcher(mvc, ant, this.registrations); + } + + static final class DispatcherServletDelegatingRequestMatcher implements RequestMatcher { + + private final MvcRequestMatcher mvc; + + private final AntPathRequestMatcher ant; + + private final ServletRegistrationCollection registrations; + + private DispatcherServletDelegatingRequestMatcher(MvcRequestMatcher mvc, AntPathRequestMatcher ant, + ServletRegistrationCollection registrations) { + this.mvc = mvc; + this.ant = ant; + this.registrations = registrations; + } + + @Override + public boolean matches(HttpServletRequest request) { + String name = request.getHttpServletMapping().getServletName(); + ServletRegistrationCollection.Registration registration = this.registrations.registrationByName(name); + Assert.notNull(registration, + String.format("Could not find %s in servlet configuration %s", name, this.registrations)); + if (registration.isDispatcherServlet()) { + return this.mvc.matches(request); + } + return this.ant.matches(request); + } + + @Override + public MatchResult matcher(HttpServletRequest request) { + String name = request.getHttpServletMapping().getServletName(); + ServletRegistrationCollection.Registration registration = this.registrations.registrationByName(name); + Assert.notNull(registration, + String.format("Could not find %s in servlet configuration %s", name, this.registrations)); + if (registration.isDispatcherServlet()) { + return this.mvc.matcher(request); + } + return this.ant.matcher(request); + } + + @Override + public String toString() { + return String.format("DispatcherServlet [mvc=[%s], ant=[%s], servlet=[%s]]", this.mvc, this.ant, + this.registrations); + } + + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MvcRequestMatcherBuilder.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MvcRequestMatcherBuilder.java new file mode 100644 index 0000000000..60122c35c5 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/MvcRequestMatcherBuilder.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2023 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.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +final class MvcRequestMatcherBuilder implements RequestMatcherBuilder { + + private static final String HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME = "mvcHandlerMappingIntrospector"; + + private final HandlerMappingIntrospector introspector; + + private final ObjectPostProcessor objectPostProcessor; + + private final String servletPath; + + private MvcRequestMatcherBuilder(ApplicationContext context, String servletPath) { + if (!context.containsBean(HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME)) { + throw new NoSuchBeanDefinitionException("A Bean named " + HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME + + " of type " + HandlerMappingIntrospector.class.getName() + + " is required to use MvcRequestMatcher. Please ensure Spring Security & Spring MVC are configured in a shared ApplicationContext."); + } + this.introspector = context.getBean(HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME, HandlerMappingIntrospector.class); + this.objectPostProcessor = context.getBean(ObjectPostProcessor.class); + this.servletPath = servletPath; + } + + static MvcRequestMatcherBuilder absolute(ApplicationContext context) { + return new MvcRequestMatcherBuilder(context, null); + } + + static MvcRequestMatcherBuilder relativeTo(ApplicationContext context, String path) { + return new MvcRequestMatcherBuilder(context, path); + } + + @Override + public MvcRequestMatcher matcher(String pattern) { + MvcRequestMatcher matcher = new MvcRequestMatcher(this.introspector, pattern); + this.objectPostProcessor.postProcess(matcher); + if (this.servletPath != null) { + matcher.setServletPath(this.servletPath); + } + return matcher; + } + + @Override + public MvcRequestMatcher matcher(HttpMethod method, String pattern) { + MvcRequestMatcher matcher = new MvcRequestMatcher(this.introspector, pattern); + this.objectPostProcessor.postProcess(matcher); + matcher.setMethod(method); + if (this.servletPath != null) { + matcher.setServletPath(this.servletPath); + } + return matcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilder.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilder.java new file mode 100644 index 0000000000..6b95bf8111 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilder.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-2023 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 java.util.ArrayList; +import java.util.List; + +import org.springframework.http.HttpMethod; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +/** + * An interface that abstracts how matchers are created + * + * @author Josh Cummings + * @since 6.2 + */ +interface RequestMatcherBuilder { + + /** + * Create a request matcher for the given pattern. + * + *

+ * For example, you might do something like the following: + * builder.matcher("/controller/**") + * + * @param pattern the pattern to use, typically an Ant path + * @return a {@link RequestMatcher} that matches on the given {@code pattern} + */ + RequestMatcher matcher(String pattern); + + /** + * Create a request matcher for the given pattern. + * + *

+ * For example, you might do something like the following: + * builder.matcher(HttpMethod.GET, "/controller/**") + * + * @param method the HTTP method to use + * @param pattern the pattern to use, typically an Ant path + * @return a {@link RequestMatcher} that matches on the given HTTP {@code method} and + * {@code pattern} + */ + RequestMatcher matcher(HttpMethod method, String pattern); + + /** + * Create a request matcher that matches any request + * @return a {@link RequestMatcher} that matches any request + */ + default RequestMatcher any() { + return AnyRequestMatcher.INSTANCE; + } + + /** + * Create an array request matchers, one for each of the given patterns. + * + *

+ * For example, you might do something like the following: + * builder.matcher("/controller-one/**", "/controller-two/**") + * + * @param patterns the patterns to use, typically Ant paths + * @return a list of {@link RequestMatcher} that match on the given {@code pattern} + */ + default List matchers(String... patterns) { + List matchers = new ArrayList<>(); + for (String pattern : patterns) { + matchers.add(matcher(pattern)); + } + return matchers; + } + + /** + * Create an array request matchers, one for each of the given patterns. + * + *

+ * For example, you might do something like the following: + * builder.matcher(HttpMethod.POST, "/controller-one/**", "/controller-two/**") + * + * @param method the HTTP method to use + * @param patterns the patterns to use, typically Ant paths + * @return a list of {@link RequestMatcher} that match on the given HTTP + * {@code method} and {@code pattern} + */ + default List matchers(HttpMethod method, String... patterns) { + List matchers = new ArrayList<>(); + for (String pattern : patterns) { + matchers.add(matcher(method, pattern)); + } + return matchers; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilders.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilders.java new file mode 100644 index 0000000000..f34793320e --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuilders.java @@ -0,0 +1,215 @@ +/* + * Copyright 2002-2023 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.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.web.servlet.DispatcherServlet; + +/** + * A factory for constructing {@link RequestMatcherBuilder} instances + * + * @author Josh Cummings + * @since 6.2 + */ +final class RequestMatcherBuilders { + + private static final String HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME = "mvcHandlerMappingIntrospector"; + + private static final String HANDLER_MAPPING_INTROSPECTOR = "org.springframework.web.servlet.handler.HandlerMappingIntrospector"; + + private static final boolean mvcPresent; + + static { + mvcPresent = ClassUtils.isPresent(HANDLER_MAPPING_INTROSPECTOR, RequestMatcherBuilders.class.getClassLoader()); + } + + private static final Log logger = LogFactory.getLog(RequestMatcherBuilders.class); + + private RequestMatcherBuilders() { + + } + + /** + * Create the default {@link RequestMatcherBuilder} for use by Spring Security DSLs. + * + *

+ * If Spring MVC is not present on the classpath or if there is no + * {@link DispatcherServlet}, this method will return an Ant-based builder. + * + *

+ * If the servlet configuration has only {@link DispatcherServlet} with a single + * mapping (for example `/` or `/path/*`), then this method will return an MVC-based + * builder. + * + *

+ * If the servlet configuration maps {@link DispatcherServlet} to a path and also has + * other servlets, this will throw an exception. In that case, an application should + * instead use the {@link RequestMatcherBuilders#createForServletPattern} ideally with + * the associated + * {@link org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer} + * to create builders by servlet path. + * + *

+ * Otherwise, (namely if {@link DispatcherServlet} is root), this method will return a + * builder that delegates to an Ant or Mvc builder at runtime. + * @param context the application context + * @return the appropriate {@link RequestMatcherBuilder} based on application + * configuration + */ + static RequestMatcherBuilder createDefault(ApplicationContext context) { + if (!mvcPresent) { + logger.trace("Defaulting to Ant matching since Spring MVC is not on the classpath"); + return AntPathRequestMatcherBuilder.absolute(); + } + if (!context.containsBean(HANDLER_MAPPING_INTROSPECTOR_BEAN_NAME)) { + logger.trace("Defaulting to Ant matching since Spring MVC is not fully configured"); + return AntPathRequestMatcherBuilder.absolute(); + } + ServletRegistrationCollection registrations = ServletRegistrationCollection.registrations(context); + if (registrations.isEmpty()) { + logger.trace("Defaulting to MVC matching since Spring MVC is on the class path and no servlet " + + "information is available"); + return AntPathRequestMatcherBuilder.absolute(); + } + ServletRegistrationCollection dispatcherServlets = registrations.dispatcherServlets(); + if (dispatcherServlets.isEmpty()) { + logger.trace("Defaulting to Ant matching since there is no DispatcherServlet configured"); + return AntPathRequestMatcherBuilder.absolute(); + } + ServletRegistrationCollection.ServletPath servletPath = registrations.deduceOneServletPath(); + if (servletPath != null) { + String message = "Defaulting to MVC matching since DispatcherServlet [%s] is the only servlet mapping"; + logger.trace(String.format(message, servletPath.path())); + return MvcRequestMatcherBuilder.relativeTo(context, servletPath.path()); + } + servletPath = dispatcherServlets.deduceOneServletPath(); + if (servletPath == null) { + logger.trace("Did not choose a default since there is more than one DispatcherServlet mapping"); + String message = String.format(""" + This method cannot decide whether these patterns are Spring MVC patterns or not + since your servlet configuration has multiple Spring MVC servlet mappings. + + For your reference, here is your servlet configuration: %s + + To address this, you need to specify the servlet path for each endpoint. + You can use .forServletPattern in conjunction with requestMatchers do to this + like so: + + @Bean + SecurityFilterChain appSecurity(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .forServletPattern("/mvc-one/*", (one) -> one + .requestMatchers("/controller/**", "/endpoints/**" + )... + .forServletPattern("/mvc-two/*", (two) -> two + .requestMatchers("/other/**", "/controllers/**")... + ) + .forServletPattern("/h2-console/*", (h2) -> h2 + .requestMatchers("/**")... + ) + ) + // ... + return http.build(); + } + """, registrations); + return new ErrorRequestMatcherBuilder(message); + } + if (servletPath.path() != null) { + logger.trace("Did not choose a default since there is a non-root DispatcherServlet mapping"); + String message = String.format(""" + This method cannot decide whether these patterns are Spring MVC patterns or not + since your Spring MVC mapping is mapped to a path and you have other servlet mappings. + + For your reference, here is your servlet configuration: %s + + To address this, you need to specify the servlet path for each endpoint. + You can use .forServletPattern in conjunction with requestMatchers do to this + like so: + + @Bean + SecurityFilterChain appSecurity(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .forServletPattern("/mvc/*", (mvc) -> mvc + .requestMatchers("/controller/**", "/endpoints/**")... + ) + .forServletPattern("/h2-console/*", (h2) -> h2 + .requestMatchers("/**")... + ) + ) + // ... + return http.build(); + } + """, registrations); + return new ErrorRequestMatcherBuilder(message); + } + logger.trace("Defaulting to request-time checker since DispatcherServlet is mapped to root, but there are also " + + "other servlet mappings"); + return new DispatcherServletDelegatingRequestMatcherBuilder(MvcRequestMatcherBuilder.absolute(context), + AntPathRequestMatcherBuilder.absolute(), registrations); + } + + static RequestMatcherBuilder createForServletPattern(ApplicationContext context, String pattern) { + Assert.notNull(pattern, "pattern cannot be null"); + ServletRegistrationCollection registrations = ServletRegistrationCollection.registrations(context); + ServletRegistrationCollection.Registration registration = registrations.registrationByMapping(pattern); + Assert.notNull(registration, () -> String + .format("The given pattern %s doesn't seem to match any configured servlets: %s", pattern, registrations)); + boolean isPathPattern = pattern.startsWith("/") && pattern.endsWith("/*"); + if (isPathPattern) { + String path = pattern.substring(0, pattern.length() - 2); + return (registration.isDispatcherServlet()) ? MvcRequestMatcherBuilder.relativeTo(context, path) + : AntPathRequestMatcherBuilder.relativeTo(path); + } + return (registration.isDispatcherServlet()) ? MvcRequestMatcherBuilder.absolute(context) + : AntPathRequestMatcherBuilder.absolute(); + } + + private static class ErrorRequestMatcherBuilder implements RequestMatcherBuilder { + + private final String errorMessage; + + ErrorRequestMatcherBuilder(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public RequestMatcher matcher(String pattern) { + throw new IllegalArgumentException(this.errorMessage); + } + + @Override + public RequestMatcher matcher(HttpMethod method, String pattern) { + throw new IllegalArgumentException(this.errorMessage); + } + + @Override + public RequestMatcher any() { + throw new IllegalArgumentException(this.errorMessage); + } + + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcher.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcher.java new file mode 100644 index 0000000000..989429a050 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcher.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2023 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 jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +final class ServletPatternRequestMatcher implements RequestMatcher { + + final String pattern; + + ServletPatternRequestMatcher(String pattern) { + Assert.notNull(pattern, "pattern cannot be null"); + this.pattern = pattern; + } + + @Override + public boolean matches(HttpServletRequest request) { + return this.pattern.equals(request.getHttpServletMapping().getPattern()); + } + + @Override + public String toString() { + return String.format("ServletPattern [pattern='%s']", this.pattern); + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletRegistrationCollection.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletRegistrationCollection.java new file mode 100644 index 0000000000..560050c0ca --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ServletRegistrationCollection.java @@ -0,0 +1,152 @@ +/* + * Copyright 2002-2023 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 java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; + +import org.springframework.context.ApplicationContext; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.WebApplicationContext; + +final class ServletRegistrationCollection { + + private List registrations; + + private ServletRegistrationCollection() { + this.registrations = Collections.emptyList(); + } + + private ServletRegistrationCollection(List registrations) { + this.registrations = registrations; + } + + static ServletRegistrationCollection registrations(ApplicationContext context) { + if (!(context instanceof WebApplicationContext web)) { + return new ServletRegistrationCollection(); + } + ServletContext servletContext = web.getServletContext(); + if (servletContext == null) { + return new ServletRegistrationCollection(); + } + Map registrations = servletContext.getServletRegistrations(); + if (registrations == null) { + return new ServletRegistrationCollection(); + } + List filtered = new ArrayList<>(); + for (ServletRegistration registration : registrations.values()) { + Collection mappings = registration.getMappings(); + if (!CollectionUtils.isEmpty(mappings)) { + filtered.add(new Registration(registration)); + } + } + return new ServletRegistrationCollection(filtered); + } + + boolean isEmpty() { + return this.registrations.isEmpty(); + } + + Registration registrationByName(String name) { + for (Registration registration : this.registrations) { + if (registration.registration().getName().equals(name)) { + return registration; + } + } + return null; + } + + Registration registrationByMapping(String target) { + for (Registration registration : this.registrations) { + for (String mapping : registration.registration().getMappings()) { + if (target.equals(mapping)) { + return registration; + } + } + } + return null; + } + + ServletRegistrationCollection dispatcherServlets() { + List dispatcherServlets = new ArrayList<>(); + for (Registration registration : this.registrations) { + if (registration.isDispatcherServlet()) { + dispatcherServlets.add(registration); + } + } + return new ServletRegistrationCollection(dispatcherServlets); + } + + ServletPath deduceOneServletPath() { + if (this.registrations.size() > 1) { + return null; + } + ServletRegistration registration = this.registrations.iterator().next().registration(); + if (registration.getMappings().size() > 1) { + return null; + } + String mapping = registration.getMappings().iterator().next(); + if ("/".equals(mapping)) { + return new ServletPath(); + } + if (mapping.endsWith("/*")) { + return new ServletPath(mapping.substring(0, mapping.length() - 2)); + } + return null; + } + + @Override + public String toString() { + Map> mappings = new LinkedHashMap<>(); + for (Registration registration : this.registrations) { + mappings.put(registration.registration().getClassName(), registration.registration().getMappings()); + } + return mappings.toString(); + } + + record Registration(ServletRegistration registration) { + boolean isDispatcherServlet() { + Class dispatcherServlet = ClassUtils + .resolveClassName("org.springframework.web.servlet.DispatcherServlet", null); + try { + Class clazz = Class.forName(this.registration.getClassName()); + if (dispatcherServlet.isAssignableFrom(clazz)) { + return true; + } + } + catch (ClassNotFoundException ex) { + return false; + } + return false; + } + } + + record ServletPath(String path) { + ServletPath() { + this(null); + } + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryNoMvcTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryNoMvcTests.java index 4d7c9a18ff..ec39518324 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryNoMvcTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryNoMvcTests.java @@ -25,8 +25,12 @@ import org.springframework.http.HttpMethod; import org.springframework.security.test.support.ClassPathExclusions; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.context.WebApplicationContext; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; /** * Tests for {@link AbstractRequestMatcherRegistry} with no Spring MVC in the classpath @@ -41,6 +45,9 @@ public class AbstractRequestMatcherRegistryNoMvcTests { @BeforeEach public void setUp() { this.matcherRegistry = new TestRequestMatcherRegistry(); + WebApplicationContext context = mock(WebApplicationContext.class); + given(context.getBeanNamesForType((Class) any())).willReturn(new String[0]); + this.matcherRegistry.setApplicationContext(context); } @Test 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 26be9c212f..92e99b1fda 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 @@ -163,12 +163,6 @@ public class AbstractRequestMatcherRegistryTests { assertThat(requestMatchers).isNotEmpty(); assertThat(requestMatchers).hasSize(1); assertThat(requestMatchers.get(0)).isExactlyInstanceOf(AntPathRequestMatcher.class); - servletContext.addServlet("servletOne", Servlet.class); - servletContext.addServlet("servletTwo", Servlet.class); - requestMatchers = this.matcherRegistry.requestMatchers("/**"); - assertThat(requestMatchers).isNotEmpty(); - assertThat(requestMatchers).hasSize(1); - assertThat(requestMatchers.get(0)).isExactlyInstanceOf(AntPathRequestMatcher.class); } @Test diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistryTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistryTests.java new file mode 100644 index 0000000000..ef29d6a86c --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AbstractRequestMatcherBuilderRegistryTests.java @@ -0,0 +1,349 @@ +/* + * Copyright 2002-2023 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 java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletContext; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.ObjectAssert; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.MockServletContext; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.support.GenericWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link AbstractRequestMatcherBuilderRegistry} + */ +class AbstractRequestMatcherBuilderRegistryTests { + + @Test + void defaultServletMatchersWhenDefaultDispatcherServletThenMvc() { + MockServletContext servletContext = MockServletContext.mvc(); + List matchers = defaultServlet(servletContext).requestMatchers("/mvc").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isNull(); + assertThatMvc(matchers).pattern().isEqualTo("/mvc"); + assertThatMvc(matchers).method().isNull(); + } + + @Test + void defaultServletHttpMethodMatchersWhenDefaultDispatcherServletThenMvc() { + MockServletContext servletContext = MockServletContext.mvc(); + List matchers = defaultServlet(servletContext).requestMatchers(HttpMethod.GET, "/mvc").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isNull(); + assertThatMvc(matchers).pattern().isEqualTo("/mvc"); + assertThatMvc(matchers).method().isEqualTo(HttpMethod.GET); + } + + @Test + void servletMatchersWhenPathDispatcherServletThenMvc() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + List matchers = servletPattern(servletContext, "/mvc/*") + .requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isEqualTo("/mvc"); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + } + + @Test + void servletMatchersWhenAlsoExtraServletContainerMappingsThenMvc() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class); + servletContext.addServlet("jspServlet", Servlet.class).addMapping("*.jsp", "*.jspx"); + servletContext.addServlet("facesServlet", Servlet.class).addMapping("/faces/", "*.jsf", "*.faces", "*.xhtml"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + List matchers = servletPattern(servletContext, "/mvc/*") + .requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isEqualTo("/mvc"); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + } + + @Test + void defaultServletMatchersWhenOnlyDefaultServletThenAnt() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + List matchers = defaultServlet(servletContext).requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(AntPathRequestMatcher.class); + assertThatAnt(matchers).pattern().isEqualTo("/controller"); + } + + @Test + void defaultDispatcherServletMatchersWhenNoHandlerMappingIntrospectorThenException() { + MockServletContext servletContext = MockServletContext.mvc(); + assertThatExceptionOfType(NoSuchBeanDefinitionException.class) + .isThrownBy(() -> defaultServlet(servletContext, (context) -> { + })); + } + + @Test + void dispatcherServletMatchersWhenNoHandlerMappingIntrospectorThenException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + assertThatExceptionOfType(NoSuchBeanDefinitionException.class) + .isThrownBy(() -> servletPattern(servletContext, (context) -> { + }, "/mvc/*")); + } + + @Test + void matchersWhenNoDispatchServletThenAnt() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("messageDispatcherServlet", Servlet.class).addMapping("/services/*"); + List matchers = defaultServlet(servletContext).requestMatchers("/services/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(AntPathRequestMatcher.class); + assertThatAnt(matchers).pattern().isEqualTo("/services/endpoint"); + } + + @Test + void servletMatchersWhenMixedServletsThenDeterminesByServletPath() { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("messageDispatcherServlet", Servlet.class).addMapping("/services/*"); + List matchers = servletPattern(servletContext, "/services/*") + .requestMatchers("/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(AntPathRequestMatcher.class); + assertThatAnt(matchers).pattern().isEqualTo("/services/endpoint"); + matchers = defaultServlet(servletContext).requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isNull(); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + } + + @Test + void servletMatchersWhenDispatcherServletNotDefaultThenDeterminesByServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + List matchers = servletPattern(servletContext, "/mvc/*") + .requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isEqualTo("/mvc"); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + matchers = defaultServlet(servletContext).requestMatchers("/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(AntPathRequestMatcher.class); + assertThatAnt(matchers).pattern().isEqualTo("/endpoint"); + } + + @Test + void servletHttpMatchersWhenDispatcherServletNotDefaultThenDeterminesByServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + List matchers = servletPattern(servletContext, "/mvc/*").requestMatchers(HttpMethod.GET, + "/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).method().isEqualTo(HttpMethod.GET); + assertThatMvc(matchers).servletPath().isEqualTo("/mvc"); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + matchers = defaultServlet(servletContext).requestMatchers(HttpMethod.GET, "/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(AntPathRequestMatcher.class); + assertThatAnt(matchers).method().isEqualTo(HttpMethod.GET); + assertThatAnt(matchers).pattern().isEqualTo("/endpoint"); + } + + @Test + void servletMatchersWhenTwoDispatcherServletsThenDeterminesByServletPath() { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("two", DispatcherServlet.class).addMapping("/other/*"); + List matchers = defaultServlet(servletContext).requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isNull(); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + matchers = servletPattern(servletContext, "/other/*").requestMatchers("/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isEqualTo("/other"); + assertThatMvc(matchers).pattern().isEqualTo("/endpoint"); + } + + @Test + void servletMatchersWhenMoreThanOneMappingThenDeterminesByServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/", "/two/*"); + List matchers = defaultServlet(servletContext).requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isNull(); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + matchers = servletPattern(servletContext, "/two/*").requestMatchers("/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isEqualTo("/two"); + assertThatMvc(matchers).pattern().isEqualTo("/endpoint"); + } + + @Test + void servletMatchersWhenMoreThanOneMappingAndDefaultServletsThenDeterminesByServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/", "/two/*"); + servletContext.addServlet("jspServlet", Servlet.class).addMapping("*.jsp", "*.jspx"); + List matchers = defaultServlet(servletContext).requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isNull(); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + matchers = servletPattern(servletContext, "/two/*").requestMatchers("/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isEqualTo("/two"); + assertThatMvc(matchers).pattern().isEqualTo("/endpoint"); + } + + @Test + void defaultServletWhenDispatcherServletThenMvc() { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("messageDispatcherServlet", Servlet.class).addMapping("/services/*"); + List matchers = defaultServlet(servletContext).requestMatchers("/controller").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(MvcRequestMatcher.class); + assertThatMvc(matchers).servletPath().isNull(); + assertThatMvc(matchers).pattern().isEqualTo("/controller"); + matchers = servletPattern(servletContext, "/services/*").requestMatchers("/endpoint").matchers; + assertThat(matchers).hasSize(1).hasOnlyElementsOfType(AntPathRequestMatcher.class); + assertThatAnt(matchers).pattern().isEqualTo("/services/endpoint"); + } + + @Test + void defaultServletWhenNoDefaultServletThenException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("messageDispatcherServlet", Servlet.class).addMapping("/services/*"); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> defaultServlet(servletContext)); + } + + @Test + void servletPathWhenNoMatchingServletThenException() { + MockServletContext servletContext = MockServletContext.mvc(); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> servletPattern(servletContext, "/wrong/*")); + } + + TestServletRequestMatcherRegistry defaultServlet(ServletContext servletContext) { + return servletPattern(servletContext, "/"); + } + + TestServletRequestMatcherRegistry defaultServlet(ServletContext servletContext, + Consumer consumer) { + return servletPattern(servletContext, consumer, "/"); + } + + TestServletRequestMatcherRegistry servletPattern(ServletContext servletContext, String pattern) { + return servletPattern(servletContext, (context) -> { + context.registerBean("mvcHandlerMappingIntrospector", HandlerMappingIntrospector.class); + context.registerBean(ObjectPostProcessor.class, () -> mock(ObjectPostProcessor.class)); + }, pattern); + } + + TestServletRequestMatcherRegistry servletPattern(ServletContext servletContext, + Consumer consumer, String pattern) { + GenericWebApplicationContext context = new GenericWebApplicationContext(servletContext); + consumer.accept(context); + context.refresh(); + return new TestServletRequestMatcherRegistry(context, pattern); + } + + static MvcRequestMatcherAssert assertThatMvc(List matchers) { + RequestMatcher matcher = matchers.get(0); + if (matcher instanceof AndRequestMatcher matching) { + List and = (List) ReflectionTestUtils.getField(matching, "requestMatchers"); + assertThat(and).hasSize(2); + assertThat(and.get(1)).isInstanceOf(MvcRequestMatcher.class); + return new MvcRequestMatcherAssert((MvcRequestMatcher) and.get(1)); + } + assertThat(matcher).isInstanceOf(MvcRequestMatcher.class); + return new MvcRequestMatcherAssert((MvcRequestMatcher) matcher); + } + + static AntPathRequestMatcherAssert assertThatAnt(List matchers) { + RequestMatcher matcher = matchers.get(0); + if (matcher instanceof AndRequestMatcher matching) { + List and = (List) ReflectionTestUtils.getField(matching, "requestMatchers"); + assertThat(and).hasSize(2); + assertThat(and.get(1)).isInstanceOf(AntPathRequestMatcher.class); + return new AntPathRequestMatcherAssert((AntPathRequestMatcher) and.get(1)); + } + assertThat(matcher).isInstanceOf(AntPathRequestMatcher.class); + return new AntPathRequestMatcherAssert((AntPathRequestMatcher) matcher); + } + + static final class TestServletRequestMatcherRegistry + extends AbstractRequestMatcherBuilderRegistry { + + List matchers; + + TestServletRequestMatcherRegistry(ApplicationContext context, String pattern) { + super(context, RequestMatcherBuilders.createForServletPattern(context, pattern)); + } + + @Override + protected TestServletRequestMatcherRegistry chainRequestMatchers(List requestMatchers) { + this.matchers = requestMatchers; + return this; + } + + } + + static final class MvcRequestMatcherAssert extends ObjectAssert { + + private MvcRequestMatcherAssert(MvcRequestMatcher matcher) { + super(matcher); + } + + AbstractObjectAssert servletPath() { + return extracting("servletPath"); + } + + AbstractObjectAssert pattern() { + return extracting("pattern"); + } + + AbstractObjectAssert method() { + return extracting("method"); + } + + } + + static final class AntPathRequestMatcherAssert extends ObjectAssert { + + private AntPathRequestMatcherAssert(AntPathRequestMatcher matcher) { + super(matcher); + } + + AbstractObjectAssert pattern() { + return extracting("pattern"); + } + + AbstractObjectAssert method() { + return extracting("httpMethod"); + } + + } + +} 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 c2d99042b0..cb8837f978 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 @@ -18,6 +18,7 @@ package org.springframework.security.config.annotation.web.configurers; import java.util.function.Supplier; +import jakarta.servlet.Servlet; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,6 +27,7 @@ import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.authentication.RememberMeAuthenticationToken; @@ -33,6 +35,7 @@ import org.springframework.security.authentication.TestAuthentication; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationEventPublisher; import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.config.MockServletContext; import org.springframework.security.config.annotation.ObjectPostProcessor; import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -58,6 +61,7 @@ import org.springframework.web.bind.annotation.PathVariable; 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; @@ -71,6 +75,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -121,7 +126,7 @@ public class AuthorizeHttpRequestsConfigurerTests { public void configureWhenMvcMatcherAfterAnyRequestThenException() { assertThatExceptionOfType(BeanCreationException.class) .isThrownBy(() -> this.spring.register(AfterAnyRequestConfig.class).autowire()) - .withMessageContaining("Can't configure mvcMatchers after anyRequest"); + .withMessageContaining("Can't configure requestMatchers after anyRequest"); } @Test @@ -362,7 +367,7 @@ public class AuthorizeHttpRequestsConfigurerTests { @Test public void getWhenServletPathRoleAdminConfiguredAndRoleIsUserThenRespondsWithForbidden() throws Exception { - this.spring.register(ServletPathConfig.class, BasicController.class).autowire(); + this.spring.register(MvcServletPathConfig.class, BasicController.class).autowire(); // @formatter:off MockHttpServletRequestBuilder requestWithUser = get("/spring/") .servletPath("/spring") @@ -375,7 +380,7 @@ public class AuthorizeHttpRequestsConfigurerTests { @Test public void getWhenServletPathRoleAdminConfiguredAndRoleIsUserAndWithoutServletPathThenRespondsWithForbidden() throws Exception { - this.spring.register(ServletPathConfig.class, BasicController.class).autowire(); + this.spring.register(MvcServletPathConfig.class, BasicController.class).autowire(); // @formatter:off MockHttpServletRequestBuilder requestWithUser = get("/") .with(user("user") @@ -386,7 +391,7 @@ public class AuthorizeHttpRequestsConfigurerTests { @Test public void getWhenServletPathRoleAdminConfiguredAndRoleIsAdminThenRespondsWithOk() throws Exception { - this.spring.register(ServletPathConfig.class, BasicController.class).autowire(); + this.spring.register(MvcServletPathConfig.class, BasicController.class).autowire(); // @formatter:off MockHttpServletRequestBuilder requestWithAdmin = get("/spring/") .servletPath("/spring") @@ -596,6 +601,200 @@ public class AuthorizeHttpRequestsConfigurerTests { this.mvc.perform(requestWithUser).andExpect(status().isForbidden()); } + @Test + public void configureWhenNoDispatcherServletThenSucceeds() throws Exception { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + this.spring.register(AuthorizeHttpRequestsConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/path")).andExpect(status().isNotFound()); + } + + @Test + public void configureWhenOnlyDispatcherServletThenSucceeds() throws Exception { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + this.spring.register(AuthorizeHttpRequestsConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/mvc/path").servletPath("/mvc")).andExpect(status().isNotFound()); + this.mvc.perform(get("/mvc")).andExpect(status().isUnauthorized()); + } + + @Test + public void configureWhenMultipleServletsThenSucceeds() throws Exception { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("path", Servlet.class).addMapping("/path/*"); + this.spring.register(AuthorizeHttpRequestsConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/path").with(servletPath("/path"))).andExpect(status().isNotFound()); + } + + @Test + public void configureWhenAmbiguousServletsThenWiringException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + servletContext.addServlet("path", Servlet.class).addMapping("/path/*"); + assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> this.spring.register(AuthorizeHttpRequestsConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire()); + } + + @Test + void defaultServletMatchersWhenDefaultServletThenPermits() throws Exception { + this.spring.register(DefaultServletConfig.class) + .postProcessor((context) -> context.setServletContext(MockServletContext.mvc())) + .autowire(); + this.mvc.perform(get("/path/path").with(defaultServlet())).andExpect(status().isNotFound()); + this.mvc.perform(get("/path/path").with(servletPath("/path"))).andExpect(status().isUnauthorized()); + } + + @Test + void defaultServletHttpMethodMatchersWhenDefaultServletThenPermits() throws Exception { + this.spring.register(DefaultServletConfig.class) + .postProcessor((context) -> context.setServletContext(MockServletContext.mvc())) + .autowire(); + this.mvc.perform(get("/path/method").with(defaultServlet())).andExpect(status().isNotFound()); + this.mvc.perform(head("/path/method").with(defaultServlet())).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/path/method").with(servletPath("/path"))).andExpect(status().isUnauthorized()); + } + + @Test + void defaultServletWhenNoDefaultServletThenWiringException() { + assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> this.spring.register(DefaultServletConfig.class) + .postProcessor((context) -> context.setServletContext(new MockServletContext())) + .autowire()); + } + + @Test + void servletPathMatchersWhenMatchingServletThenPermits() throws Exception { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("path", Servlet.class).addMapping("/path/*"); + this.spring.register(ServletPathConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/path/path").with(servletPath("/path"))).andExpect(status().isNotFound()); + this.mvc.perform(get("/path/path").with(defaultServlet())).andExpect(status().isUnauthorized()); + } + + @Test + void servletPathHttpMethodMatchersWhenMatchingServletThenPermits() throws Exception { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("path", Servlet.class).addMapping("/path/*"); + this.spring.register(ServletPathConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/path/method").with(servletPath("/path"))).andExpect(status().isNotFound()); + this.mvc.perform(head("/path/method").with(servletPath("/path"))).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/path/method").with(defaultServlet())).andExpect(status().isUnauthorized()); + } + + @Test + void servletPathWhenNoMatchingPathThenWiringException() { + MockServletContext servletContext = MockServletContext.mvc(); + assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> this.spring.register(ServletPathConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire()); + } + + @Test + void servletMappingMatchersWhenMatchingServletThenPermits() throws Exception { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("jsp", Servlet.class).addMapping("*.jsp"); + this.spring.register(ServletMappingConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/path/file.jsp").with(servletExtension(".jsp"))).andExpect(status().isNotFound()); + this.mvc.perform(get("/path/file.jsp").with(defaultServlet())).andExpect(status().isUnauthorized()); + } + + @Test + void servletMappingHttpMethodMatchersWhenMatchingServletThenPermits() throws Exception { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("jsp", Servlet.class).addMapping("*.jsp"); + this.spring.register(ServletMappingConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/method/file.jsp").with(servletExtension(".jsp"))).andExpect(status().isNotFound()); + this.mvc.perform(head("/method/file.jsp").with(servletExtension(".jsp"))).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/method/file.jsp").with(defaultServlet())).andExpect(status().isUnauthorized()); + } + + @Test + void servletMappingWhenNoMatchingExtensionThenWiringException() { + MockServletContext servletContext = MockServletContext.mvc(); + assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> this.spring.register(ServletMappingConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire()); + } + + @Test + void anyRequestWhenUsedWithDefaultServletThenDoesNotWire() { + assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> this.spring.register(MixedServletEndpointConfig.class).autowire()) + .withMessageContaining("forServletPattern"); + } + + @Test + void servletWhenNoMatchingPathThenDenies() throws Exception { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("jspServlet", Servlet.class).addMapping("*.jsp"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + this.spring.register(DefaultServletAndServletPathConfig.class) + .postProcessor((context) -> context.setServletContext(servletContext)) + .autowire(); + this.mvc.perform(get("/js/color.js").with(servletPath("/js"))).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/mvc/controller").with(defaultServlet())).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/js/color.js").with(defaultServlet())).andExpect(status().isNotFound()); + this.mvc.perform(get("/mvc/controller").with(servletPath("/mvc"))).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/mvc/controller").with(user("user")).with(servletPath("/mvc"))) + .andExpect(status().isNotFound()); + } + + @Test + void permitAllWhenDefaultServletThenDoesNotWire() { + assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> this.spring.register(MixedServletPermitAllConfig.class).autowire()) + .withMessageContaining("forServletPattern"); + } + + static RequestPostProcessor defaultServlet() { + return (request) -> { + String uri = request.getRequestURI(); + request.setHttpServletMapping(TestMockHttpServletMappings.defaultMapping()); + request.setServletPath(uri); + request.setPathInfo(""); + return request; + }; + } + + static RequestPostProcessor servletPath(String path) { + return (request) -> { + String uri = request.getRequestURI(); + request.setHttpServletMapping(TestMockHttpServletMappings.path(request, path)); + request.setServletPath(path); + request.setPathInfo(uri.substring(path.length())); + return request; + }; + } + + static RequestPostProcessor servletExtension(String extension) { + return (request) -> { + String uri = request.getRequestURI(); + request.setHttpServletMapping(TestMockHttpServletMappings.extension(request, extension)); + request.setServletPath(uri); + request.setPathInfo(""); + return request; + }; + } + @Configuration @EnableWebSecurity static class GrantedAuthorityDefaultHasRoleConfig { @@ -693,6 +892,7 @@ public class AuthorizeHttpRequestsConfigurerTests { @Configuration @EnableWebSecurity + @EnableWebMvc static class AfterAnyRequestConfig { @Bean @@ -954,7 +1154,7 @@ public class AuthorizeHttpRequestsConfigurerTests { @Configuration @EnableWebMvc @EnableWebSecurity - static class ServletPathConfig { + static class MvcServletPathConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception { @@ -1136,6 +1336,163 @@ public class AuthorizeHttpRequestsConfigurerTests { } + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class AuthorizeHttpRequestsConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // @formatter:off + http + .httpBasic(withDefaults()) + .authorizeHttpRequests((requests) -> requests + .requestMatchers("/path/**").permitAll() + .anyRequest().authenticated() + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class DefaultServletConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // @formatter:off + http + .httpBasic(withDefaults()) + .authorizeHttpRequests((requests) -> requests + .forServletPattern("/", (root) -> root + .requestMatchers(HttpMethod.GET, "/path/method/**").permitAll() + .requestMatchers("/path/path/**").permitAll() + .anyRequest().authenticated() + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class ServletPathConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // @formatter:off + http + .httpBasic(withDefaults()) + .authorizeHttpRequests((requests) -> requests + .forServletPattern("/path/*", (root) -> root + .requestMatchers(HttpMethod.GET, "/method/**").permitAll() + .requestMatchers("/path/**").permitAll() + .anyRequest().authenticated() + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class ServletMappingConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // @formatter:off + http + .httpBasic(withDefaults()) + .authorizeHttpRequests((requests) -> requests + .forServletPattern("*.jsp", (jsp) -> jsp + .requestMatchers(HttpMethod.GET, "/method/**").permitAll() + .requestMatchers("/path/**").permitAll() + .anyRequest().authenticated() + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class MixedServletEndpointConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // @formatter:off + http + .httpBasic(withDefaults()) + .authorizeHttpRequests((requests) -> requests + .forServletPattern("/", (root) -> root.anyRequest().permitAll()) + .anyRequest().authenticated() + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class MixedServletPermitAllConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // @formatter:off + http + .formLogin((form) -> form.loginPage("/page").permitAll()) + .authorizeHttpRequests((requests) -> requests + .forServletPattern("/", (root) -> root + .anyRequest().authenticated() + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class DefaultServletAndServletPathConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // @formatter:off + http + .httpBasic(withDefaults()) + .authorizeHttpRequests((requests) -> requests + .forServletPattern("/", (root) -> root + .requestMatchers("/js/**", "/css/**").permitAll() + ) + .forServletPattern("/mvc/*", (mvc) -> mvc + .requestMatchers("/controller/**").authenticated() + ) + .forServletPattern("*.jsp", (jsp) -> jsp + .anyRequest().authenticated() + ) + ); + // @formatter:on + return http.build(); + } + + } + @Configuration static class AuthorizationEventPublisherConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuildersTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuildersTests.java new file mode 100644 index 0000000000..61fdf6b2be --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestMatcherBuildersTests.java @@ -0,0 +1,198 @@ +/* + * Copyright 2002-2023 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 java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletContext; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.security.config.MockServletContext; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.configurers.DispatcherServletDelegatingRequestMatcherBuilder.DispatcherServletDelegatingRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.support.GenericWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +public class RequestMatcherBuildersTests { + + @Test + void matchersWhenDefaultDispatcherServletThenMvc() { + MockServletContext servletContext = MockServletContext.mvc(); + RequestMatcherBuilder builder = requestMatchersBuilder(servletContext); + List matchers = builder.matchers("/mvc"); + assertThat(matchers.get(0)).isInstanceOf(MvcRequestMatcher.class); + MvcRequestMatcher matcher = (MvcRequestMatcher) matchers.get(0); + assertThat(ReflectionTestUtils.getField(matcher, "servletPath")).isNull(); + assertThat(ReflectionTestUtils.getField(matcher, "pattern")).isEqualTo("/mvc"); + } + + @Test + void httpMethodMatchersWhenDefaultDispatcherServletThenMvc() { + MockServletContext servletContext = MockServletContext.mvc(); + RequestMatcherBuilder builder = requestMatchersBuilder(servletContext); + List matchers = builder.matchers(HttpMethod.GET, "/mvc"); + assertThat(matchers.get(0)).isInstanceOf(MvcRequestMatcher.class); + MvcRequestMatcher matcher = (MvcRequestMatcher) matchers.get(0); + assertThat(ReflectionTestUtils.getField(matcher, "servletPath")).isNull(); + assertThat(ReflectionTestUtils.getField(matcher, "pattern")).isEqualTo("/mvc"); + assertThat(ReflectionTestUtils.getField(matcher, "method")).isEqualTo(HttpMethod.GET); + } + + @Test + void matchersWhenPathDispatcherServletThenMvc() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + RequestMatcherBuilder builder = requestMatchersBuilder(servletContext); + List matchers = builder.matchers("/controller"); + assertThat(matchers.get(0)).isInstanceOf(MvcRequestMatcher.class); + MvcRequestMatcher matcher = (MvcRequestMatcher) matchers.get(0); + assertThat(ReflectionTestUtils.getField(matcher, "servletPath")).isEqualTo("/mvc"); + assertThat(ReflectionTestUtils.getField(matcher, "pattern")).isEqualTo("/controller"); + } + + @Test + void matchersWhenAlsoExtraServletContainerMappingsThenRequiresServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("jspServlet", Servlet.class).addMapping("*.jsp", "*.jspx"); + servletContext.addServlet("facesServlet", Servlet.class).addMapping("/faces/", "*.jsf", "*.faces", "*.xhtml"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatchersBuilder(servletContext).matcher("/path")) + .withMessageContaining(".forServletPattern"); + } + + @Test + void matchersWhenOnlyDefaultServletThenAnt() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + RequestMatcherBuilder builder = requestMatchersBuilder(servletContext); + List matchers = builder.matchers("/controller"); + assertThat(matchers.get(0)).isInstanceOf(AntPathRequestMatcher.class); + AntPathRequestMatcher matcher = (AntPathRequestMatcher) matchers.get(0); + assertThat(ReflectionTestUtils.getField(matcher, "pattern")).isEqualTo("/controller"); + } + + @Test + void matchersWhenNoHandlerMappingIntrospectorThenAnt() { + MockServletContext servletContext = MockServletContext.mvc(); + RequestMatcherBuilder builder = requestMatchersBuilder(servletContext, (context) -> { + }); + List matchers = builder.matchers("/controller"); + assertThat(matchers.get(0)).isInstanceOf(AntPathRequestMatcher.class); + AntPathRequestMatcher matcher = (AntPathRequestMatcher) matchers.get(0); + assertThat(ReflectionTestUtils.getField(matcher, "pattern")).isEqualTo("/controller"); + } + + @Test + void matchersWhenNoDispatchServletThenAnt() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("messageDispatcherServlet", Servlet.class).addMapping("/services/*"); + RequestMatcherBuilder builder = requestMatchersBuilder(servletContext); + List matchers = builder.matchers("/services/endpoint"); + assertThat(matchers.get(0)).isInstanceOf(AntPathRequestMatcher.class); + AntPathRequestMatcher matcher = (AntPathRequestMatcher) matchers.get(0); + assertThat(ReflectionTestUtils.getField(matcher, "pattern")).isEqualTo("/services/endpoint"); + } + + @Test + void matchersWhenMixedServletsThenServletPathDelegating() { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("messageDispatcherServlet", Servlet.class).addMapping("/services/*"); + RequestMatcherBuilder builder = requestMatchersBuilder(servletContext); + assertThat(builder.matchers("/services/endpoint").get(0)) + .isInstanceOf(DispatcherServletDelegatingRequestMatcher.class); + } + + @Test + void matchersWhenDispatcherServletNotDefaultAndOtherServletsThenRequiresServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatchersBuilder(servletContext).matcher("/path/**")) + .withMessageContaining(".forServletPattern"); + } + + @Test + void httpMatchersWhenDispatcherServletNotDefaultAndOtherServletsThenRequiresServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("default", Servlet.class).addMapping("/"); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatchersBuilder(servletContext).matcher("/pattern")) + .withMessageContaining(".forServletPattern"); + } + + @Test + void matchersWhenTwoDispatcherServletsThenException() { + MockServletContext servletContext = MockServletContext.mvc(); + servletContext.addServlet("two", DispatcherServlet.class).addMapping("/other/*"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatchersBuilder(servletContext).matcher("/path/**")) + .withMessageContaining(".forServletPattern"); + } + + @Test + void matchersWhenMoreThanOneMappingThenException() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/", "/two/*"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatchersBuilder(servletContext).matcher("/path/**")) + .withMessageContaining(".forServletPattern"); + } + + @Test + void matchersWhenMoreThanOneMappingAndDefaultServletsThenRequiresServletPath() { + MockServletContext servletContext = new MockServletContext(); + servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/", "/two/*"); + servletContext.addServlet("jspServlet", Servlet.class).addMapping("*.jsp", "*.jspx"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> requestMatchersBuilder(servletContext).matcher("/path/**")) + .withMessageContaining(".forServletPattern"); + } + + RequestMatcherBuilder requestMatchersBuilder(ServletContext servletContext) { + return requestMatchersBuilder(servletContext, (context) -> { + context.registerBean("mvcHandlerMappingIntrospector", HandlerMappingIntrospector.class, + () -> mock(HandlerMappingIntrospector.class)); + context.registerBean(ObjectPostProcessor.class, () -> mock(ObjectPostProcessor.class)); + }); + } + + RequestMatcherBuilder requestMatchersBuilder(ServletContext servletContext, + Consumer consumer) { + GenericWebApplicationContext context = new GenericWebApplicationContext(servletContext); + consumer.accept(context); + context.refresh(); + return RequestMatcherBuilders.createDefault(context); + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcherTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcherTests.java new file mode 100644 index 0000000000..5c287a6936 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ServletPatternRequestMatcherTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2023 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.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ServletPatternRequestMatcher} + */ +class ServletPatternRequestMatcherTests { + + ServletPatternRequestMatcher matcher = new ServletPatternRequestMatcher("*.jsp"); + + @Test + void matchesWhenDefaultServletThenTrue() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/a/uri.jsp"); + request.setHttpServletMapping(TestMockHttpServletMappings.extension(request, ".jsp")); + assertThat(this.matcher.matches(request)).isTrue(); + } + + @Test + void matchesWhenNotDefaultServletThenFalse() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/a/uri.jsp"); + request.setHttpServletMapping(TestMockHttpServletMappings.path(request, "/a")); + request.setServletPath("/a/uri.jsp"); + assertThat(this.matcher.matches(request)).isFalse(); + } + + @Test + void matcherWhenDefaultServletThenTrue() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/a/uri.jsp"); + request.setHttpServletMapping(TestMockHttpServletMappings.extension(request, ".jsp")); + request.setServletPath("/a/uri.jsp"); + assertThat(this.matcher.matcher(request).isMatch()).isTrue(); + } + + @Test + void matcherWhenNotDefaultServletThenFalse() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/a/uri.jsp"); + request.setHttpServletMapping(TestMockHttpServletMappings.path(request, "/a")); + request.setServletPath("/a/uri.jsp"); + assertThat(this.matcher.matcher(request).isMatch()).isFalse(); + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/TestMockHttpServletMappings.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/TestMockHttpServletMappings.java new file mode 100644 index 0000000000..8ab2d42aed --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/TestMockHttpServletMappings.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2023 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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.MappingMatch; + +import org.springframework.mock.web.MockHttpServletMapping; + +final class TestMockHttpServletMappings { + + private TestMockHttpServletMappings() { + + } + + static MockHttpServletMapping extension(HttpServletRequest request, String extension) { + String uri = request.getRequestURI(); + String matchValue = uri.substring(0, uri.lastIndexOf(extension)); + return new MockHttpServletMapping(matchValue, "*" + extension, "extension", MappingMatch.EXTENSION); + } + + static MockHttpServletMapping path(HttpServletRequest request, String path) { + String uri = request.getRequestURI(); + String matchValue = uri.substring(path.length()); + return new MockHttpServletMapping(matchValue, path + "/*", "path", MappingMatch.PATH); + } + + static MockHttpServletMapping defaultMapping() { + return new MockHttpServletMapping("", "/", "default", MappingMatch.DEFAULT); + } + +} 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 298b7f8ad3..7dac6eb233 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc @@ -571,70 +571,156 @@ http { ---- ==== +[[match-by-servlet-path]] +[[mvc-not-default-servlet]] [[match-by-mvc]] -=== Using an MvcRequestMatcher +=== Matching by Servlet Pattern -Generally speaking, you can use `requestMatchers(String)` as demonstrated above. +Generally speaking, you can use `requestMatchers(String...)` and `requestMatchers(HttpMethod, 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. +For example, if Spring MVC is mapped to `/mvc` instead of `/` (the default), then you may have an endpoint like `/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: +If you have multiple servlets, and `DispatcherServlet` is mapped in this way, you'll see an error that's something like this: + +[source,bash] +---- +This method cannot decide whether these patterns are Spring MVC patterns or not + +... + +For your reference, here is your servlet configuration: {default=[/], dispatcherServlet=[/mvc/*]} + +To address this, you need to specify the servlet path or pattern for each endpoint. +You can use .forServletPattern in conjunction with requestMatchers do to this +---- + +You can use `.forServletPattern` (or construct your own `MvcRequestMatcher` instance) to split the servlet path and the controller path in your configuration, like so: .Match by MvcRequestMatcher ==== .Java [source,java,role="primary"] ---- -@Bean -MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) { - return new MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc"); -} - @Bean SecurityFilterChain appEndpoints(HttpSecurity http, MvcRequestMatcher.Builder mvc) { http .authorizeHttpRequests((authorize) -> authorize - .requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller") - .anyRequest().authenticated() + .forServletPattern("/mvc/*", (mvc) -> mvc + .requestMatchers("/my/resource/**").hasAuthority("resource:read") + .anyRequest().authenticated() + ) ); return http.build(); } ---- - -.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 = - http { - authorizeHttpRequests { - authorize(mvc.pattern("/my/controller/**"), hasAuthority("controller")) - authorize(anyRequest, authenticated) - } - } ----- - -.Xml -[source,xml,role="secondary"] ----- - - - - ----- ==== +where `/mvc/*` is the matching pattern in your servlet configuration listed in the error message. + This need can arise in at least two different ways: * 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) +* If you register more than one Spring MVC `DispatcherServlet` (thus requiring that one of them not be the default servlet) + +Note that when either of these cases come up, all URIs need to be fully-qualified as above. + +For example, consider a more sophisticated setup where you have Spring MVC resources mapped to `/mvc/*` and Spring Boot H2 Console mapped to `/h2-console/*`. +In that case, each URI can be made absolute, listing the servlet path like so: + +.Match by Servlet Path +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityFilterChain appSecurity(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .forServletPattern("/mvc/*", (mvc) -> mvc + .requestMatchers("/my/resource/**").hasAuthority("resource:read") + ) + .forServletPattern("/h2-console/*", (h2) -> h2 + .anyRequest().hasAuthority("h2") + ) + ) + // ... +} +---- +==== + +Alternatively, you can do one of three things to remove the need to disambiguate: + +1. Always deploy `DispatcherServlet` to `/` (the default behavior) ++ +When `DispatcherServlet` is mapped to `/`, it's clear that all the URIs supplied in `requestMatchers(String)` are absolute URIs. +Because of that, there is no ambiguity when interpreting them. ++ +2. Remove all other servlets ++ +When there is only `DispatcherServlet`, it's clear that all the URIs supplied in `requestMatchers(String)` are relative to the Spring MVC configuration. +Because of that, there is no ambiguity when interpreting them. + +At times, servlet containers add other servlets by default that you aren't actually using. +So, if these aren't needed, remove them, bringing you down to just `DispatcherServlet`. ++ +3. Create an `HttpRequestHandler` so that `DispatcherServlet` dispatches to your servlets instead of your servlet container. ++ +If you are deploying Spring MVC to a separate path to allow your container to serve static resources, consider instead {spring-framework-reference-url}web/webmvc/mvc-config/default-servlet-handler.html#page-title[notifying Spring MVC about this]. +Or, if you have a custom servlet, publishing {spring-framework-api-url}org/springframework/web/servlet/mvc/HttpRequestHandlerAdapter.html[a custom `HttpRequestHandler` bean within {spring-framework-api-url}org/springframework/web/servlet/DispatcherServlet.html[the `DispatcherServlet` configuration] instead. ++ + +=== Matching by the Default Servlet + +You can also match more generally by the matching pattern specified in your servlet configuration. + +For example, to match the default servlet (whichever servlet is mapped to `/`), use `forServletPattern` like so: + +.Match by the Default Servlet +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityFilterChain appSecurity(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .forServletPattern("/", (root) -> root + .requestMatchers("/my/resource/**").hasAuthority("resource:read") + ) + ) + // ... +} +---- +==== + +Such will match on requests that the servlet container matches to your default servlet that start with the URI `/my/resource`. + +=== Matching by an Extension Servlet + +Or, to match to an extension servlet (like a servlet mapped to `*.jsp`), use `forServletPattern` as follows: + +.Match by an Extension Servlet +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityFilterChain appSecurity(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .forServletPattern("*.jsp", (jsp) -> jsp + .requestMatchers("/my/resource/**").hasAuthority("resource:read") + ) + ) + // ... +} +---- +==== + +Such will match on requests that the servlet container matches to your `*.jsp` servlet that start with the URI `/my/resource` (for example a request like `/my/resource/page.jsp`). [[match-by-custom]] === Using a Custom Matcher