From cfdadfea01a6206947e10274f05bdcc4f520e0a0 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 22 May 2026 21:36:19 -0600 Subject: [PATCH] Apply Builder Bean Only to Application Endpoints A published PathPatternRequestMatcher.Builder bean is intended to align application endpoint matchers, which commonly are from the same servlet. This commit ensures that this bean is used by securityMatcher(s), authorizeHttpRequests, and WebSecurity#ignoring, which target application endpoints typically served by servlets. This commit also adds tests ensuring that the bean is not applied to filter-chain security endpoints such as those configured by formLogin, logout, oneTimeTokenLogin, OAuth 2.0 Login, and SAML 2.0 Login, since these endpoints are, by default, handled by filters independent from any target servlet and servlet path. Closes gh-19128 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../annotation/web/builders/HttpSecurity.java | 4 +- .../HttpSecurityConfiguration.java | 4 +- .../web/builders/WebSecurityTests.java | 62 +++++++++++++++++++ .../configurers/FormLoginConfigurerTests.java | 43 +++++++++++++ .../configurers/LogoutConfigurerTests.java | 35 +++++++++++ .../PasswordManagementConfigurerTests.java | 34 ++++++++++ .../RequestCacheConfigurerTests.java | 41 ++++++++++++ .../client/OAuth2LoginConfigurerTests.java | 38 ++++++++++++ .../ott/OneTimeTokenLoginConfigurerTests.java | 49 +++++++++++++++ .../saml2/Saml2LoginConfigurerTests.java | 36 +++++++++++ .../saml2/Saml2LogoutConfigurerTests.java | 38 ++++++++++++ .../saml2/Saml2MetadataConfigurerTests.java | 35 +++++++++++ .../pages/servlet/authentication/logout.adoc | 6 ++ .../servlet/authentication/onetimetoken.adoc | 7 +++ .../authentication/passwords/form.adoc | 9 +++ .../authorize-http-requests.adoc | 2 + .../FormLoginServletPathConfiguration.java | 46 ++++++++++++++ .../FormLoginServletPathConfiguration.kt | 32 ++++++++++ 18 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.java create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.kt diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index 6ef44031c5..c96b6db804 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -2035,7 +2035,9 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder matchers = new ArrayList<>(); - PathPatternRequestMatcher.Builder builder = getSharedObject(PathPatternRequestMatcher.Builder.class); + ApplicationContext context = getSharedObject(ApplicationContext.class); + PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(() -> getSharedObject(PathPatternRequestMatcher.Builder.class)); for (String pattern : patterns) { matchers.add(builder.matcher(pattern)); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java index e84e4f1910..efde27ad3f 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java @@ -238,9 +238,7 @@ class HttpSecurityConfiguration { Map, Object> sharedObjects = new HashMap<>(); sharedObjects.put(ApplicationContext.class, this.context); sharedObjects.put(ContentNegotiationStrategy.class, this.contentNegotiationStrategy); - sharedObjects.put(PathPatternRequestMatcher.Builder.class, - this.context.getBeanProvider(PathPatternRequestMatcher.Builder.class) - .getIfUnique(() -> constructRequestMatcherBuilder(this.context))); + sharedObjects.put(PathPatternRequestMatcher.Builder.class, constructRequestMatcherBuilder(this.context)); return sharedObjects; } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityTests.java index 418f5e181f..34520754ef 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityTests.java @@ -37,6 +37,7 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockServletContext; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.userdetails.PasswordEncodedUser; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; @@ -110,6 +111,21 @@ public class WebSecurityTests { assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); } + // gh-19128 + @Test + public void ignoringWhenBuilderBeanWithBasePathThenHonorsBasePath() throws Exception { + loadConfig(IgnoringBuilderBeanConfig.class); + this.request.setServletPath("/spring"); + this.request.setRequestURI("/spring/path"); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + setup(); + this.request.setServletPath(""); + this.request.setRequestURI("/path"); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + } + public void loadConfig(Class... configs) { this.context = new AnnotationConfigWebApplicationContext(); this.context.register(configs); @@ -201,6 +217,52 @@ public class WebSecurityTests { } + // gh-19128 + @EnableWebSecurity + @Configuration + @EnableWebMvc + static class IgnoringBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().requestMatchers("/path"); + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .httpBasic(withDefaults()) + .authorizeHttpRequests((requests) -> requests + .anyRequest().denyAll()); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.user()); + } + + @RestController + static class PathController { + + @RequestMapping("/path") + String path() { + return "path"; + } + + } + + } + @Configuration @EnableWebSecurity static class RequestRejectedHandlerConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java index 3bee5bc0d4..d355c82693 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java @@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.config.users.AuthenticationTestConfiguration; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.authority.FactorGrantedAuthority; import org.springframework.security.core.context.SecurityContextChangedListener; import org.springframework.security.core.context.SecurityContextHolderStrategy; @@ -153,6 +154,17 @@ public class FormLoginConfigurerTests { // @formatter:on } + // gh-19128 + @Test + public void loginWhenBuilderBeanWithBasePathThenLoginProcessingUrlIgnoresBasePath() throws Exception { + this.spring.register(FormLoginBuilderBeanConfig.class).autowire(); + // @formatter:off + this.mockMvc.perform(formLogin().user("invalid")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + @Test public void loginWhenFormLoginConfiguredThenHasDefaultSuccessUrl() throws Exception { this.spring.register(FormLoginConfig.class).autowire(); @@ -519,6 +531,37 @@ public class FormLoginConfigurerTests { } + // gh-19128 + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class FormLoginBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((requests) -> requests + .anyRequest().authenticated()) + .formLogin(withDefaults()); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.user()); + } + + } + @Configuration @EnableWebSecurity static class FormLoginInLambdaConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java index 79cd3b0459..7c1087af4b 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java @@ -35,6 +35,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.PasswordEncodedUser; import org.springframework.security.core.userdetails.UserDetailsService; @@ -127,6 +128,17 @@ public class LogoutConfigurerTests { // @formatter:on } + // gh-19128 + @Test + public void logoutWhenBuilderBeanWithBasePathThenLogoutUrlIgnoresBasePath() throws Exception { + this.spring.register(LogoutBuilderBeanConfig.class).autowire(); + // @formatter:off + this.mvc.perform(post("/logout").with(csrf())) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?logout")); + // @formatter:on + } + // SEC-2311 @Test public void logoutWhenGetRequestAndCsrfDisabledThenRedirectsToLogin() throws Exception { @@ -524,6 +536,29 @@ public class LogoutConfigurerTests { } + // gh-19128 + @Configuration + @EnableWebSecurity + static class LogoutBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .logout(withDefaults()); + // @formatter:on + return http.build(); + } + + } + @Configuration @EnableWebSecurity static class CsrfDisabledConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java index 0b65e42d4d..f90626fa80 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java @@ -26,6 +26,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.web.servlet.MockMvc; @@ -66,6 +67,16 @@ public class PasswordManagementConfigurerTests { .andExpect(redirectedUrl("/custom-change-password-page")); } + // gh-19128 + @Test + public void changePasswordWhenBuilderBeanWithBasePathThenChangePasswordUrlIgnoresBasePath() throws Exception { + this.spring.register(PasswordManagementBuilderBeanConfig.class).autowire(); + + this.mvc.perform(get("/.well-known/change-password")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/change-password")); + } + @Test public void whenSettingNullChangePasswordPage() { PasswordManagementConfigurer configurer = new PasswordManagementConfigurer(); @@ -102,6 +113,29 @@ public class PasswordManagementConfigurerTests { } + // gh-19128 + @Configuration + @EnableWebSecurity + static class PasswordManagementBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + return http + .passwordManagement(withDefaults()) + .build(); + // @formatter:on + } + + } + @Configuration @EnableWebSecurity static class PasswordManagementWithCustomChangePasswordPageConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java index f4d4849467..cce83b88b5 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/RequestCacheConfigurerTests.java @@ -34,6 +34,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.userdetails.User; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.test.web.servlet.RequestCacheResultMatcher; @@ -185,6 +186,21 @@ public class RequestCacheConfigurerTests { this.mvc.perform(formLogin(session)).andExpect(redirectedUrl("/")); } + // gh-19128 + @Test + public void getWhenBuilderBeanWithBasePathThenSavedRequestMatcherIgnoresBasePath() throws Exception { + this.spring.register(RequestCacheBuilderBeanConfig.class, DefaultSecurityConfig.class).autowire(); + MockHttpServletRequestBuilder request = get("/messages").header(HttpHeaders.ACCEPT, MediaType.TEXT_HTML); + // @formatter:off + MockHttpSession session = (MockHttpSession) this.mvc.perform(request) + .andExpect(redirectedUrl("/login")) + .andReturn() + .getRequest() + .getSession(); + // @formatter:on + this.mvc.perform(formLogin(session)).andExpect(RequestCacheResultMatcher.redirectToCachedRequest()); + } + @Test public void getWhenBookmarkedRequestIsAllMediaTypeThenPostAuthenticationRemembers() throws Exception { this.spring.register(RequestCacheDefaultsConfig.class, DefaultSecurityConfig.class).autowire(); @@ -401,6 +417,31 @@ public class RequestCacheConfigurerTests { } + // gh-19128 + @Configuration + @EnableWebSecurity + static class RequestCacheBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((requests) -> requests + .anyRequest().authenticated()) + .formLogin(withDefaults()); + // @formatter:on + return http.build(); + } + + } + @Configuration @EnableWebSecurity static class RequestCacheDisabledConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java index 2bb49d775c..5aadbdcb2b 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java @@ -56,6 +56,7 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.cli import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.context.DelegatingApplicationListener; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -440,6 +441,18 @@ public class OAuth2LoginConfigurerTests { then(redirectStrategy).should().sendRedirect(any(), any(), anyString()); } + // gh-19128 + @Test + public void oauth2LoginWhenBuilderBeanWithBasePathThenLoginProcessingUrlIgnoresBasePath() throws Exception { + loadConfig(OAuth2LoginBuilderBeanConfig.class); + String requestUri = "/login/oauth2/code/google"; + this.request = get(requestUri).build(); + this.request.setParameter("code", "code123"); + this.request.setParameter("state", "state123"); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.filterChain); + assertThat(this.response.getRedirectedUrl()).endsWith("/login?error"); + } + // gh-5347 @Test public void oauth2LoginWithOneClientConfiguredThenRedirectForAuthorization() throws Exception { @@ -788,6 +801,31 @@ public class OAuth2LoginConfigurerTests { } + // gh-19128 + @Configuration + @EnableWebSecurity + static class OAuth2LoginBuilderBeanConfig extends CommonSecurityFilterChainConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .oauth2Login((login) -> login + .clientRegistrationRepository( + new InMemoryClientRegistrationRepository(GOOGLE_CLIENT_REGISTRATION))); + // @formatter:on + return super.configureFilterChain(http); + } + + } + @Configuration @EnableWebSecurity static class OAuth2LoginConfigFormLogin extends CommonSecurityFilterChainConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java index f12d40cfc3..4bc474f253 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java @@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.userdetails.PasswordEncodedUser; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; @@ -98,6 +99,19 @@ public class OneTimeTokenLoginConfigurerTests { .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); } + // gh-19128 + @Test + void oneTimeTokenWhenBuilderBeanWithBasePathThenGenerateAndLoginUrlsIgnoreBasePath() throws Exception { + this.spring.register(OneTimeTokenBuilderBeanConfig.class).autowire(); + this.mvc.perform(post("/ott/generate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + String token = getLastToken().getTokenValue(); + + this.mvc.perform(post("/login/ott").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + } + @Test void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() throws Exception { this.spring.register(OneTimeTokenDifferentUrlsConfig.class).autowire(); @@ -296,6 +310,41 @@ public class OneTimeTokenLoginConfigurerTests { } + // gh-19128 + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, + OneTimeTokenGenerationSuccessHandler ottSuccessHandler) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .oneTimeTokenLogin((ott) -> ott + .tokenGenerationSuccessHandler(ottSuccessHandler) + ); + // @formatter:on + return http.build(); + } + + @Bean + TestOneTimeTokenGenerationSuccessHandler ottSuccessHandler() { + return new TestOneTimeTokenGenerationSuccessHandler(); + } + + } + @Configuration(proxyBeanMethods = false) @EnableWebSecurity @Import(UserDetailsServiceConfig.class) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java index 5289bc9140..36be1a2ea5 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java @@ -57,6 +57,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -244,6 +245,14 @@ public class Saml2LoginConfigurerTests { performSaml2Login("ROLE_AUTH_MANAGER"); } + // gh-19128 + @Test + public void saml2LoginWhenBuilderBeanWithBasePathThenAuthenticateUriIgnoresBasePath() throws Exception { + this.spring.register(Saml2LoginBuilderBeanConfig.class).autowire(); + MvcResult result = this.mvc.perform(get("/saml2/authenticate/registration-id")).andReturn(); + assertThat(result.getResponse().getRedirectedUrl()).contains("SAMLRequest"); + } + @Test public void authenticationRequestWhenAuthenticationRequestResolverBeanThenUses() throws Exception { this.spring.register(CustomAuthenticationRequestResolverBean.class).autowire(); @@ -522,6 +531,33 @@ public class Saml2LoginConfigurerTests { } + // gh-19128 + @Configuration + @EnableWebSecurity + @Import(Saml2LoginConfigBeans.class) + static class Saml2LoginBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .saml2Login(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + + } + @Configuration @EnableWebSecurity @Import(Saml2LoginConfigBeans.class) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java index aae4bd16ca..97f35ccc73 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java @@ -49,6 +49,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.saml2.core.Saml2Utils; @@ -196,6 +197,17 @@ public class Saml2LogoutConfigurerTests { verify(logoutHandler).logout(any(), any(), any()); } + // gh-19128 + @Test + public void saml2LogoutWhenBuilderBeanWithBasePathThenLogoutUrlIgnoresBasePath() throws Exception { + this.spring.register(Saml2LogoutBuilderBeanConfig.class).autowire(); + MvcResult result = this.mvc.perform(post("/logout").with(authentication(this.user)).with(csrf())) + .andExpect(status().isFound()) + .andReturn(); + String location = result.getResponse().getHeader("Location"); + assertThat(location).startsWith("https://ap.example.org/logout/saml2/request"); + } + @Test public void saml2LogoutWhenUnauthenticatedThenEntryPoint() throws Exception { this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); @@ -577,6 +589,32 @@ public class Saml2LogoutConfigurerTests { } + // gh-19128 + @Configuration + @EnableWebSecurity + @Import(Saml2LoginConfigBeans.class) + static class Saml2LogoutBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain web(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .saml2Login(withDefaults()) + .saml2Logout(withDefaults()); + // @formatter:on + return http.build(); + } + + } + @Configuration @EnableWebSecurity @Import(Saml2LoginConfigBeans.class) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java index 470e99aaea..ea59a1b9ff 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java @@ -30,6 +30,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean; import org.springframework.security.saml2.provider.service.metadata.OpenSaml5MetadataResolver; import org.springframework.security.saml2.provider.service.metadata.RequestMatcherMetadataResponseResolver; import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponse; @@ -124,6 +125,15 @@ public class Saml2MetadataConfigurerTests { this.mvc.perform(get("/saml2/metadata")).andExpect(status().isForbidden()); } + // gh-19128 + @Test + void saml2MetadataWhenBuilderBeanWithBasePathThenMetadataUrlIgnoresBasePath() throws Exception { + this.spring.register(MetadataBuilderBeanConfig.class).autowire(); + this.mvc.perform(get("/saml/metadata")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("md:EntityDescriptor"))); + } + @EnableWebSecurity @Configuration @Import(RelyingPartyRegistrationConfig.class) @@ -141,6 +151,31 @@ public class Saml2MetadataConfigurerTests { } + // gh-19128 + @EnableWebSecurity + @Configuration + @Import(RelyingPartyRegistrationConfig.class) + static class MetadataBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .saml2Metadata((saml2) -> saml2.metadataUrl("/saml/metadata")); + return http.build(); + // @formatter:on + } + + } + @EnableWebSecurity @Configuration @Import(RelyingPartyRegistrationConfig.class) diff --git a/docs/modules/ROOT/pages/servlet/authentication/logout.adoc b/docs/modules/ROOT/pages/servlet/authentication/logout.adoc index 80c09ca9cd..c9e51882d0 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/logout.adoc @@ -81,6 +81,12 @@ Xml:: and no authorization changes are necessary since it simply adjusts the `LogoutFilter`. +[NOTE] +==== +The URI passed to `logoutUrl` (and to `logoutSuccessUrl`) is matched and redirected to literally. +If you want either URI to live under your application servlet's base path, include that prefix explicitly — for example, `logoutUrl("/api/logout")`. +==== + [[permit-logout-endpoints]] However, if you stand up your own logout success endpoint (or in a rare case, <>), say using {spring-framework-reference-url}web.html#spring-web[Spring MVC], you will need to permit it in Spring Security. This is because Spring MVC processes your request after Spring Security does. diff --git a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc index d401a821b5..f78f60a8a2 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc @@ -252,6 +252,13 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSucc ---- ====== +[NOTE] +==== +The URI passed to `generateTokenUrl` is matched literally. +If you want it to live under your application servlet's base path, include that prefix explicitly — for example, `generateTokenUrl("/api/ott/generate")`. +The same applies to `loginProcessingUrl` and `tokenGeneratingUrl` elsewhere on this page. +==== + [[changing-submit-page-url]] == Changing the Default Submit Page URL diff --git a/docs/modules/ROOT/pages/servlet/authentication/passwords/form.adoc b/docs/modules/ROOT/pages/servlet/authentication/passwords/form.adoc index afb1c30046..9f224a2229 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/passwords/form.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/passwords/form.adoc @@ -155,6 +155,15 @@ open fun filterChain(http: HttpSecurity): SecurityFilterChain { ---- ====== +[NOTE] +==== +The URI passed to `loginPage` (and to `loginProcessingUrl`, if set separately) is matched and redirected to literally. +If your custom login page is served by an application servlet mapped under a base path, include that prefix in the URI you pass to the DSL. +For example, if your dispatcher is at `/api/*`: + +include-code::./FormLoginServletPathConfiguration[tag=loginPage,indent=0] +==== + [[servlet-authentication-form-custom-html]] When the login page is specified in the Spring Security configuration, you are responsible for rendering the page. // FIXME: default login page rendered by Spring Security 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 a5e5639854..9faea0428a 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc @@ -642,6 +642,8 @@ This is because Spring Security requires all URIs to be absolute (minus the cont There are several other components that create request matchers for you like {spring-boot-api-url}org/springframework/boot/security/autoconfigure/web/servlet/PathRequest.html[`PathRequest#toStaticResources#atCommonLocations`] ===== +If most of your authorization rules sit under the same servlet path, you can xref:servlet/integrations/mvc.adoc#mvc-requestmatcher[publish a `PathPatternRequestMatcher.Builder` bean] with that base path; Spring Security then applies it to the string overloads of `authorizeHttpRequests((authorize) -> authorize.requestMatchers(...))`, `HttpSecurity#securityMatcher(...)` / `#securityMatchers(...)`, and `WebSecurityCustomizer#ignoring().requestMatchers(...)`. + [[match-by-custom]] === Using a Custom Matcher diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.java new file mode 100644 index 0000000000..aa074fba46 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2004-present 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.docs.servlet.authentication.passwords.formloginservletpath; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class FormLoginServletPathConfiguration { + + // tag::loginPage[] + @Bean + SecurityFilterChain springSecurity(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin((form) -> form + .loginPage("/api/login") + .loginProcessingUrl("/api/login") + ); + // @formatter:on + return http.build(); + } + // end::loginPage[] + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.kt new file mode 100644 index 0000000000..b3ce2447c3 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/passwords/formloginservletpath/FormLoginServletPathConfiguration.kt @@ -0,0 +1,32 @@ +package org.springframework.security.kt.docs.servlet.authentication.passwords.formloginservletpath + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.web.SecurityFilterChain + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class FormLoginServletPathConfiguration { + + // tag::loginPage[] + @Bean + fun springSecurity(http: HttpSecurity): SecurityFilterChain { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + formLogin { + loginPage = "/api/login" + loginProcessingUrl = "/api/login" + } + } + return http.build() + // @formatter:on + } + // end::loginPage[] + +}