From d121ab95654300f60e4f9af859f247d8cda66257 Mon Sep 17 00:00:00 2001 From: Evgeniy Cheban Date: Fri, 12 Jun 2020 18:00:51 +0300 Subject: [PATCH] Support A Well-Known URL for Changing Passwords Closes gh-8657 --- .../security/config/Elements.java | 5 +- .../annotation/web/builders/HttpSecurity.java | 40 ++++++ .../PasswordManagementConfigurer.java | 62 ++++++++++ .../config/http/HttpConfigurationBuilder.java | 16 +++ .../security/config/http/SecurityFilters.java | 5 +- ...ownChangePasswordBeanDefinitionParser.java | 61 +++++++++ .../config/web/server/ServerHttpSecurity.java | 103 ++++++++++++++++ .../web/server/ServerHttpSecurityDsl.kt | 32 ++++- .../web/server/ServerPasswordManagementDsl.kt | 36 ++++++ .../config/web/servlet/HttpSecurityDsl.kt | 29 +++++ .../web/servlet/PasswordManagementDsl.kt | 39 ++++++ .../security/config/spring-security-5.6.rnc | 10 +- .../security/config/spring-security-5.6.xsd | 19 +++ .../PasswordManagementConfigurerTests.java | 116 ++++++++++++++++++ ...angePasswordBeanDefinitionParserTests.java | 65 ++++++++++ .../server/PasswordManagementSpecTests.java | 79 ++++++++++++ .../ServerPasswordManagementDslTests.kt | 97 +++++++++++++++ .../web/servlet/PasswordManagementDslTests.kt | 84 +++++++++++++ ...onParserTests-CustomChangePasswordPage.xml | 32 +++++ ...nParserTests-DefaultChangePasswordPage.xml | 32 +++++ .../_includes/servlet/appendix/namespace.adoc | 16 +++ .../web/RequestMatcherRedirectFilter.java | 71 +++++++++++ .../ExchangeMatcherRedirectWebFilter.java | 70 +++++++++++ .../RequestMatcherRedirectFilterTests.java | 105 ++++++++++++++++ ...ExchangeMatcherRedirectWebFilterTests.java | 87 +++++++++++++ 25 files changed, 1307 insertions(+), 4 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java create mode 100644 config/src/main/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParser.java create mode 100644 config/src/main/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDsl.kt create mode 100644 config/src/main/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDsl.kt create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java create mode 100644 config/src/test/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests.java create mode 100644 config/src/test/java/org/springframework/security/config/web/server/PasswordManagementSpecTests.java create mode 100644 config/src/test/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDslTests.kt create mode 100644 config/src/test/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDslTests.kt create mode 100644 config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-CustomChangePasswordPage.xml create mode 100644 config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-DefaultChangePasswordPage.xml create mode 100644 web/src/main/java/org/springframework/security/web/RequestMatcherRedirectFilter.java create mode 100644 web/src/main/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilter.java create mode 100644 web/src/test/java/org/springframework/security/web/RequestMatcherRedirectFilterTests.java create mode 100644 web/src/test/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilterTests.java diff --git a/config/src/main/java/org/springframework/security/config/Elements.java b/config/src/main/java/org/springframework/security/config/Elements.java index 55e0dbaa30..0b79c47d65 100644 --- a/config/src/main/java/org/springframework/security/config/Elements.java +++ b/config/src/main/java/org/springframework/security/config/Elements.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 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. @@ -20,6 +20,7 @@ package org.springframework.security.config; * Contains all the element names used by Spring Security 3 namespace support. * * @author Ben Alex + * @author Evgeniy Cheban */ public abstract class Elements { @@ -135,4 +136,6 @@ public abstract class Elements { public static final String CLIENT_REGISTRATIONS = "client-registrations"; + public static final String PASSWORD_MANAGEMENT = "password-management"; + } 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 a34bd9c875..5282eed49e 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 @@ -59,6 +59,7 @@ import org.springframework.security.config.annotation.web.configurers.HeadersCon import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; import org.springframework.security.config.annotation.web.configurers.JeeConfigurer; import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; +import org.springframework.security.config.annotation.web.configurers.PasswordManagementConfigurer; import org.springframework.security.config.annotation.web.configurers.PortMapperConfigurer; import org.springframework.security.config.annotation.web.configurers.RememberMeConfigurer; import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; @@ -2682,6 +2683,45 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilderExample Configuration The example below demonstrates how to configure + * password management for an application. The default change password page is + * "/change-password", but can be customized using + * {@link PasswordManagementConfigurer#changePasswordPage(String)}. + * + *
+	 * @Configuration
+	 * @EnableWebSecurity
+	 * public class PasswordManagementSecurityConfig extends WebSecurityConfigurerAdapter {
+	 *
+	 * 	@Override
+	 * 	protected void configure(HttpSecurity http) throws Exception {
+	 * 		http
+	 * 			.authorizeRequests(authorizeRequests ->
+	 * 				authorizeRequests
+	 * 					.antMatchers("/**").hasRole("USER")
+	 * 			)
+	 * 			.passwordManagement(passwordManagement ->
+	 * 				passwordManagement
+	 * 					.changePasswordPage("/custom-change-password-page")
+	 * 			);
+	 *  }
+	 * }
+	 * 
+ * @param passwordManagementCustomizer the {@link Customizer} to provide more options + * for the {@link PasswordManagementConfigurer} + * @return the {@link HttpSecurity} for further customizations + * @throws Exception + * @since 5.6 + */ + public HttpSecurity passwordManagement( + Customizer> passwordManagementCustomizer) throws Exception { + passwordManagementCustomizer.customize(getOrApply(new PasswordManagementConfigurer<>())); + return HttpSecurity.this; + } + @Override public void setSharedObject(Class sharedType, C object) { super.setSharedObject(sharedType, object); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java new file mode 100644 index 0000000000..0f9b52f657 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2021 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.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.web.RequestMatcherRedirectFilter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.Assert; + +/** + * Adds password management support. + * + * @author Evgeniy Cheban + * @since 5.6 + */ +public final class PasswordManagementConfigurer> + extends AbstractHttpConfigurer, B> { + + private static final String WELL_KNOWN_CHANGE_PASSWORD_PATTERN = "/.well-known/change-password"; + + private static final String DEFAULT_CHANGE_PASSWORD_PAGE = "/change-password"; + + private String changePasswordPage = DEFAULT_CHANGE_PASSWORD_PAGE; + + /** + * Sets the change password page. Defaults to + * {@link PasswordManagementConfigurer#DEFAULT_CHANGE_PASSWORD_PAGE}. + * @param changePasswordPage the change password page + * @return the {@link PasswordManagementConfigurer} for further customizations + */ + public PasswordManagementConfigurer changePasswordPage(String changePasswordPage) { + Assert.hasText(changePasswordPage, "changePasswordPage cannot be empty"); + this.changePasswordPage = changePasswordPage; + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public void configure(B http) throws Exception { + RequestMatcherRedirectFilter changePasswordFilter = new RequestMatcherRedirectFilter( + new AntPathRequestMatcher(WELL_KNOWN_CHANGE_PASSWORD_PATTERN), this.changePasswordPage); + http.addFilterBefore(postProcess(changePasswordFilter), UsernamePasswordAuthenticationFilter.class); + } + +} diff --git a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java index 61269f0933..e3339b3b13 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java @@ -176,6 +176,8 @@ class HttpConfigurationBuilder { private BeanDefinition csrfFilter; + private BeanDefinition wellKnownChangePasswordRedirectFilter; + private BeanMetadataElement csrfLogoutHandler; private BeanMetadataElement csrfAuthStrategy; @@ -210,6 +212,7 @@ class HttpConfigurationBuilder { createFilterSecurityInterceptor(authenticationManager); createAddHeadersFilter(); createCorsFilter(); + createWellKnownChangePasswordRedirectFilter(); } private void validateInterceptUrls(ParserContext pc) { @@ -694,6 +697,15 @@ class HttpConfigurationBuilder { this.csrfLogoutHandler = this.csrfParser.getCsrfLogoutHandler(); } + private void createWellKnownChangePasswordRedirectFilter() { + Element element = DomUtils.getChildElementByTagName(this.httpElt, Elements.PASSWORD_MANAGEMENT); + if (element == null) { + return; + } + WellKnownChangePasswordBeanDefinitionParser parser = new WellKnownChangePasswordBeanDefinitionParser(); + this.wellKnownChangePasswordRedirectFilter = parser.parse(element, this.pc); + } + BeanMetadataElement getCsrfLogoutHandler() { return this.csrfLogoutHandler; } @@ -744,6 +756,10 @@ class HttpConfigurationBuilder { if (this.csrfFilter != null) { filters.add(new OrderDecorator(this.csrfFilter, SecurityFilters.CSRF_FILTER)); } + if (this.wellKnownChangePasswordRedirectFilter != null) { + filters.add(new OrderDecorator(this.wellKnownChangePasswordRedirectFilter, + SecurityFilters.WELL_KNOWN_CHANGE_PASSWORD_REDIRECT_FILTER)); + } return filters; } diff --git a/config/src/main/java/org/springframework/security/config/http/SecurityFilters.java b/config/src/main/java/org/springframework/security/config/http/SecurityFilters.java index d2c34c9ac5..c9b053a3b6 100644 --- a/config/src/main/java/org/springframework/security/config/http/SecurityFilters.java +++ b/config/src/main/java/org/springframework/security/config/http/SecurityFilters.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 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. @@ -22,6 +22,7 @@ package org.springframework.security.config.http; * * @author Luke Taylor * @author Rob Winch + * @author Evgeniy Cheban */ enum SecurityFilters { @@ -80,6 +81,8 @@ enum SecurityFilters { OAUTH2_AUTHORIZATION_CODE_GRANT_FILTER, + WELL_KNOWN_CHANGE_PASSWORD_REDIRECT_FILTER, + SESSION_MANAGEMENT_FILTER, EXCEPTION_TRANSLATION_FILTER, diff --git a/config/src/main/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParser.java new file mode 100644 index 0000000000..8b719ec1b0 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParser.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2021 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.http; + +import org.w3c.dom.Element; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.xml.BeanDefinitionParser; +import org.springframework.beans.factory.xml.ParserContext; +import org.springframework.security.web.RequestMatcherRedirectFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.StringUtils; + +/** + * The bean definition parser for a Well-Known URL for Changing Passwords. + * + * @author Evgeniy Cheban + * @since 5.6 + */ +public final class WellKnownChangePasswordBeanDefinitionParser implements BeanDefinitionParser { + + private static final String WELL_KNOWN_CHANGE_PASSWORD_PATTERN = "/.well-known/change-password"; + + private static final String DEFAULT_CHANGE_PASSWORD_PAGE = "/change-password"; + + private static final String ATT_CHANGE_PASSWORD_PAGE = "change-password-page"; + + /** + * {@inheritDoc} + */ + @Override + public BeanDefinition parse(Element element, ParserContext parserContext) { + BeanDefinition changePasswordFilter = BeanDefinitionBuilder + .rootBeanDefinition(RequestMatcherRedirectFilter.class) + .addConstructorArgValue(new AntPathRequestMatcher(WELL_KNOWN_CHANGE_PASSWORD_PATTERN)) + .addConstructorArgValue(getChangePasswordPage(element)).getBeanDefinition(); + parserContext.getReaderContext().registerWithGeneratedName(changePasswordFilter); + return changePasswordFilter; + } + + private String getChangePasswordPage(Element element) { + String changePasswordPage = element.getAttribute(ATT_CHANGE_PASSWORD_PAGE); + return (StringUtils.hasText(changePasswordPage) ? changePasswordPage : DEFAULT_CHANGE_PASSWORD_PAGE); + } + +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 585a523acd..10851d23a3 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -104,6 +104,7 @@ import org.springframework.security.web.authentication.preauth.x509.SubjectDnX50 import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint; import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint.DelegateEntry; +import org.springframework.security.web.server.ExchangeMatcherRedirectWebFilter; import org.springframework.security.web.server.MatcherSecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; @@ -261,6 +262,8 @@ public class ServerHttpSecurity { private HttpBasicSpec httpBasic; + private PasswordManagementSpec passwordManagement; + private X509Spec x509; private final RequestCacheSpec requestCache = new RequestCacheSpec(); @@ -683,6 +686,56 @@ public class ServerHttpSecurity { return this; } + /** + * Configures password management. An example configuration is provided below: + * + *
+	 *  @Bean
+	 *  public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 *      http
+	 *          // ...
+	 *          .passwordManagement();
+	 *      return http.build();
+	 *  }
+	 * 
+ * @return the {@link PasswordManagementSpec} to customize + * @since 5.6 + */ + public PasswordManagementSpec passwordManagement() { + if (this.passwordManagement == null) { + this.passwordManagement = new PasswordManagementSpec(); + } + return this.passwordManagement; + } + + /** + * Configures password management. An example configuration is provided below: + * + *
+	 *  @Bean
+	 *  public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 *      http
+	 *          // ...
+	 *          .passwordManagement(passwordManagement ->
+	 *          	// Custom change password page.
+	 *          	passwordManagement.changePasswordPage("/custom-change-password-page")
+	 *          );
+	 *      return http.build();
+	 *  }
+	 * 
+ * @param passwordManagementCustomizer the {@link Customizer} to provide more options + * for the {@link PasswordManagementSpec} + * @return the {@link ServerHttpSecurity} to customize + * @since 5.6 + */ + public ServerHttpSecurity passwordManagement(Customizer passwordManagementCustomizer) { + if (this.passwordManagement == null) { + this.passwordManagement = new PasswordManagementSpec(); + } + passwordManagementCustomizer.customize(this.passwordManagement); + return this; + } + /** * Configures form based authentication. An example configuration is provided below: * @@ -1348,6 +1401,9 @@ public class ServerHttpSecurity { } this.httpBasic.configure(this); } + if (this.passwordManagement != null) { + this.passwordManagement.configure(this); + } if (this.formLogin != null) { if (this.formLogin.authenticationManager == null) { this.formLogin.authenticationManager(this.authenticationManager); @@ -2018,6 +2074,53 @@ public class ServerHttpSecurity { } + /** + * Configures password management. + * + * @author Evgeniy Cheban + * @since 5.6 + * @see #passwordManagement() + */ + public final class PasswordManagementSpec { + + private static final String WELL_KNOWN_CHANGE_PASSWORD_PATTERN = "/.well-known/change-password"; + + private static final String DEFAULT_CHANGE_PASSWORD_PAGE = "/change-password"; + + private String changePasswordPage = DEFAULT_CHANGE_PASSWORD_PAGE; + + /** + * Sets the change password page. Defaults to + * {@link PasswordManagementSpec#DEFAULT_CHANGE_PASSWORD_PAGE}. + * @param changePasswordPage the change password page + * @return the {@link PasswordManagementSpec} to continue configuring + */ + public PasswordManagementSpec changePasswordPage(String changePasswordPage) { + Assert.hasText(changePasswordPage, "changePasswordPage cannot be empty"); + this.changePasswordPage = changePasswordPage; + return this; + } + + /** + * Allows method chaining to continue configuring the {@link ServerHttpSecurity}. + * @return the {@link ServerHttpSecurity} to continue configuring + */ + public ServerHttpSecurity and() { + return ServerHttpSecurity.this; + } + + protected void configure(ServerHttpSecurity http) { + ExchangeMatcherRedirectWebFilter changePasswordWebFilter = new ExchangeMatcherRedirectWebFilter( + new PathPatternParserServerWebExchangeMatcher(WELL_KNOWN_CHANGE_PASSWORD_PATTERN), + this.changePasswordPage); + http.addFilterBefore(changePasswordWebFilter, SecurityWebFiltersOrder.AUTHENTICATION); + } + + private PasswordManagementSpec() { + } + + } + /** * Configures Form Based authentication * diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt index e7b485e2bb..57260b3d96 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 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. @@ -251,6 +251,36 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in this.http.httpBasic(httpBasicCustomizer) } + /** + * Enables password management. + * + * Example: + * + * ``` + * @EnableWebFluxSecurity + * class SecurityConfig { + * + * @Bean + * fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + * return http { + * passwordManagement { + * changePasswordPage = "/custom-change-password-page" + * } + * } + * } + * } + * ``` + * + * @param passwordManagementConfiguration custom configuration to be applied to the + * password management + * @see [ServerPasswordManagementDsl] + * @since 5.6 + */ + fun passwordManagement(passwordManagementConfiguration: ServerPasswordManagementDsl.() -> Unit) { + val passwordManagementCustomizer = ServerPasswordManagementDsl().apply(passwordManagementConfiguration).get() + this.http.passwordManagement(passwordManagementCustomizer) + } + /** * Allows configuring response headers. * diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDsl.kt new file mode 100644 index 0000000000..bc6afbf8b3 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDsl.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.web.server + +/** + * A Kotlin DSL to configure [ServerHttpSecurity] password management + * using idiomatic Kotlin code. + * + * @author Evgeniy Cheban + * @property changePasswordPage the change password page. + * @since 5.6 + */ +@ServerSecurityMarker +class ServerPasswordManagementDsl { + var changePasswordPage: String? = null + + internal fun get(): (ServerHttpSecurity.PasswordManagementSpec) -> Unit { + return { passwordManagement -> + changePasswordPage?.also { passwordManagement.changePasswordPage(changePasswordPage) } + } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt index f368522abf..494405ee24 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt @@ -222,6 +222,35 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu this.http.httpBasic(httpBasicCustomizer) } + /** + * Enables password management. + * + * Example: + * + * ``` + * @EnableWebSecurity + * class SecurityConfig : WebSecurityConfigurerAdapter() { + * + * override fun configure(http: HttpSecurity) { + * http { + * passwordManagement { + * changePasswordPage = "/custom-change-password-page" + * } + * } + * } + * } + * ``` + * + * @param passwordManagementConfiguration custom configurations to be applied to the + * password management + * @see [PasswordManagementDsl] + * @since 5.6 + */ + fun passwordManagement(passwordManagementConfiguration: PasswordManagementDsl.() -> Unit) { + val passwordManagementCustomizer = PasswordManagementDsl().apply(passwordManagementConfiguration).get() + this.http.passwordManagement(passwordManagementCustomizer) + } + /** * Allows configuring response headers. * diff --git a/config/src/main/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDsl.kt new file mode 100644 index 0000000000..474dca8704 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDsl.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.web.servlet + +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.PasswordManagementConfigurer + +/** + * A Kotlin DSL to configure [HttpSecurity] password management + * using idiomatic Kotlin code. + * + * @author Evgeniy Cheban + * @property changePasswordPage the change password page. + * @since 5.6 + */ +@SecurityMarker +class PasswordManagementDsl { + var changePasswordPage: String? = null + + internal fun get(): (PasswordManagementConfigurer) -> Unit { + return { passwordManagement -> + changePasswordPage?.also { passwordManagement.changePasswordPage(changePasswordPage) } + } + } +} diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-5.6.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-5.6.rnc index 72f0eb673b..2a21aebb41 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-5.6.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-5.6.rnc @@ -312,7 +312,7 @@ http-firewall = http = ## Container element for HTTP security configuration. Multiple elements can now be defined, each with a specific pattern to which the enclosed security configuration applies. A pattern can also be configured to bypass Spring Security's filters completely by setting the "security" attribute to "none". - element http {http.attlist, (intercept-url* & access-denied-handler? & form-login? & oauth2-login? & oauth2-client? & oauth2-resource-server? & openid-login? & x509? & jee? & http-basic? & logout? & session-management & remember-me? & anonymous? & port-mappings & custom-filter* & request-cache? & expression-handler? & headers? & csrf? & cors?) } + element http {http.attlist, (intercept-url* & access-denied-handler? & form-login? & oauth2-login? & oauth2-client? & oauth2-resource-server? & openid-login? & x509? & jee? & http-basic? & logout? & password-management? & session-management & remember-me? & anonymous? & port-mappings & custom-filter* & request-cache? & expression-handler? & headers? & csrf? & cors?) } http.attlist &= ## The request URL pattern which will be mapped to the filter chain created by this element. If omitted, the filter chain will match all requests. attribute pattern {xsd:token}? @@ -703,6 +703,14 @@ http-basic.attlist &= ## Reference to an AuthenticationDetailsSource which will be used by the authentication filter attribute authentication-details-source-ref {xsd:token}? +password-management = + ## Adds support for the password management. + element password-management {password-management.attlist, empty} + +password-management.attlist &= + ## The change password page. Defaults to "/change-password". + attribute change-password-page {xsd:string}? + session-management = ## Session-management related functionality is implemented by the addition of a SessionManagementFilter to the filter stack. element session-management {session-management.attlist, concurrency-control?} diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-5.6.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-5.6.xsd index e8004b3efe..7f6006dc4c 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-5.6.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-5.6.xsd @@ -1066,6 +1066,7 @@ + Session-management related functionality is implemented by the addition of a @@ -2161,6 +2162,23 @@ + + + Adds support for the password management. + + + + + + + + + + The change password page. Defaults to "/change-password". + + + + @@ -3255,6 +3273,7 @@ + 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 new file mode 100644 index 0000000000..23eae5a8da --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-2021 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.Rule; +import org.junit.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.test.SpringTestRule; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link PasswordManagementConfigurer}. + * + * @author Evgeniy Cheban + */ +public class PasswordManagementConfigurerTests { + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Autowired + MockMvc mvc; + + @Test + public void whenChangePasswordPageNotSetThenDefaultChangePasswordPageUsed() throws Exception { + this.spring.register(PasswordManagementWithDefaultChangePasswordPageConfig.class).autowire(); + + this.mvc.perform(get("/.well-known/change-password")).andExpect(status().isFound()) + .andExpect(redirectedUrl("/change-password")); + } + + @Test + public void whenChangePasswordPageSetThenSpecifiedChangePasswordPageUsed() throws Exception { + this.spring.register(PasswordManagementWithCustomChangePasswordPageConfig.class).autowire(); + + this.mvc.perform(get("/.well-known/change-password")).andExpect(status().isFound()) + .andExpect(redirectedUrl("/custom-change-password-page")); + } + + @Test + public void whenSettingNullChangePasswordPage() { + PasswordManagementConfigurer configurer = new PasswordManagementConfigurer(); + assertThatIllegalArgumentException().isThrownBy(() -> configurer.changePasswordPage(null)) + .withMessage("changePasswordPage cannot be empty"); + } + + @Test + public void whenSettingEmptyChangePasswordPage() { + PasswordManagementConfigurer configurer = new PasswordManagementConfigurer(); + assertThatIllegalArgumentException().isThrownBy(() -> configurer.changePasswordPage("")) + .withMessage("changePasswordPage cannot be empty"); + } + + @Test + public void whenSettingBlankChangePasswordPage() { + PasswordManagementConfigurer configurer = new PasswordManagementConfigurer(); + assertThatIllegalArgumentException().isThrownBy(() -> configurer.changePasswordPage(" ")) + .withMessage("changePasswordPage cannot be empty"); + } + + @EnableWebSecurity + static class PasswordManagementWithDefaultChangePasswordPageConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + return http + .passwordManagement(withDefaults()) + .build(); + // @formatter:on + } + + } + + @EnableWebSecurity + static class PasswordManagementWithCustomChangePasswordPageConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + return http + .passwordManagement((passwordManagement) -> passwordManagement + .changePasswordPage("/custom-change-password-page") + ) + .build(); + // @formatter:on + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests.java new file mode 100644 index 0000000000..25772e847e --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2021 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.http; + +import org.junit.Rule; +import org.junit.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.test.SpringTestRule; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link WellKnownChangePasswordBeanDefinitionParser}. + * + * @author Evgeniy Cheban + */ +public class WellKnownChangePasswordBeanDefinitionParserTests { + + private static final String CONFIG_LOCATION_PREFIX = "classpath:org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests"; + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Autowired + MockMvc mvc; + + @Test + public void whenChangePasswordPageNotSetThenDefaultChangePasswordPageUsed() throws Exception { + this.spring.configLocations(xml("DefaultChangePasswordPage")).autowire(); + + this.mvc.perform(get("/.well-known/change-password")).andExpect(status().isFound()) + .andExpect(redirectedUrl("/change-password")); + } + + @Test + public void whenChangePasswordPageSetThenSpecifiedChangePasswordPageUsed() throws Exception { + this.spring.configLocations(xml("CustomChangePasswordPage")).autowire(); + + this.mvc.perform(get("/.well-known/change-password")).andExpect(status().isFound()) + .andExpect(redirectedUrl("/custom-change-password-page")); + } + + private String xml(String configName) { + return CONFIG_LOCATION_PREFIX + "-" + configName + ".xml"; + } + +} diff --git a/config/src/test/java/org/springframework/security/config/web/server/PasswordManagementSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/PasswordManagementSpecTests.java new file mode 100644 index 0000000000..dfe2ec55f0 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/server/PasswordManagementSpecTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.web.server; + +import org.apache.http.HttpHeaders; +import org.junit.Test; + +import org.springframework.security.config.annotation.web.reactive.ServerHttpSecurityConfigurationBuilder; +import org.springframework.security.config.web.server.ServerHttpSecurity.PasswordManagementSpec; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link PasswordManagementSpec}. + * + * @author Evgeniy Cheban + */ +public class PasswordManagementSpecTests { + + ServerHttpSecurity http = ServerHttpSecurityConfigurationBuilder.httpWithDefaultAuthentication(); + + @Test + public void whenChangePasswordPageNotSetThenDefaultChangePasswordPageUsed() { + this.http.passwordManagement(); + + WebTestClient client = buildClient(); + client.get().uri("/.well-known/change-password").exchange().expectStatus().isFound().expectHeader() + .valueEquals(HttpHeaders.LOCATION, "/change-password"); + } + + @Test + public void whenChangePasswordPageSetThenSpecifiedChangePasswordPageUsed() { + this.http.passwordManagement( + (passwordManagement) -> passwordManagement.changePasswordPage("/custom-change-password-page")); + + WebTestClient client = buildClient(); + client.get().uri("/.well-known/change-password").exchange().expectStatus().isFound().expectHeader() + .valueEquals(HttpHeaders.LOCATION, "/custom-change-password-page"); + } + + private WebTestClient buildClient() { + return WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); + } + + @Test + public void whenSettingNullChangePasswordPage() { + assertThatIllegalArgumentException().isThrownBy(() -> this.http.passwordManagement().changePasswordPage(null)) + .withMessage("changePasswordPage cannot be empty"); + } + + @Test + public void whenSettingEmptyChangePasswordPage() { + assertThatIllegalArgumentException().isThrownBy(() -> this.http.passwordManagement().changePasswordPage("")) + .withMessage("changePasswordPage cannot be empty"); + } + + @Test + public void whenSettingBlankChangePasswordPage() { + assertThatIllegalArgumentException().isThrownBy(() -> this.http.passwordManagement().changePasswordPage(" ")) + .withMessage("changePasswordPage cannot be empty"); + } + +} diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDslTests.kt new file mode 100644 index 0000000000..6f98a581b6 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerPasswordManagementDslTests.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.web.server + +import org.apache.http.HttpHeaders +import org.junit.Rule +import org.junit.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.test.SpringTestRule +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.web.reactive.config.EnableWebFlux + +/** + * Tests for [ServerPasswordManagementDsl]. + * + * @author Evgeniy Cheban + */ +class ServerPasswordManagementDslTests { + + @Rule + @JvmField + val spring = SpringTestRule() + + private lateinit var client: WebTestClient + + @Autowired + fun setup(context: ApplicationContext) { + this.client = WebTestClient + .bindToApplicationContext(context) + .configureClient() + .build() + } + + @Test + fun `when change password page not set then default change password page used`() { + this.spring.register(PasswordManagementWithDefaultChangePasswordPageConfig::class.java).autowire() + + this.client.get() + .uri("/.well-known/change-password") + .exchange() + .expectStatus().isFound + .expectHeader().valueEquals(HttpHeaders.LOCATION, "/change-password") + } + + @EnableWebFluxSecurity + @EnableWebFlux + open class PasswordManagementWithDefaultChangePasswordPageConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + passwordManagement {} + } + } + } + + @Test + fun `when change password page set then specified change password page used`() { + this.spring.register(PasswordManagementWithCustomChangePasswordPageConfig::class.java).autowire() + + this.client.get() + .uri("/.well-known/change-password") + .exchange() + .expectStatus().isFound + .expectHeader().valueEquals(HttpHeaders.LOCATION, "/custom-change-password-page") + } + + @EnableWebFluxSecurity + @EnableWebFlux + open class PasswordManagementWithCustomChangePasswordPageConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + passwordManagement { + changePasswordPage = "/custom-change-password-page" + } + } + } + } +} diff --git a/config/src/test/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDslTests.kt new file mode 100644 index 0000000000..f4d2c1ee00 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/web/servlet/PasswordManagementDslTests.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.web.servlet + +import org.junit.Rule +import org.junit.Test +import org.springframework.beans.factory.annotation.Autowired +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.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.config.test.SpringTestRule +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +/** + * Tests for [PasswordManagementDsl]. + * + * @author Evgeniy Cheban + */ +class PasswordManagementDslTests { + + @Rule + @JvmField + val spring = SpringTestRule() + + @Autowired + lateinit var mockMvc: MockMvc + + @Test + fun `when change password page not set then default change password page used`() { + this.spring.register(PasswordManagementWithDefaultChangePasswordPageConfig::class.java).autowire() + + this.mockMvc.get("/.well-known/change-password") + .andExpect { + status { isFound() } + redirectedUrl("/change-password") + } + } + + @EnableWebSecurity + open class PasswordManagementWithDefaultChangePasswordPageConfig : WebSecurityConfigurerAdapter() { + override fun configure(http: HttpSecurity) { + http { + passwordManagement {} + } + } + } + + @Test + fun `when change password page set then specified change password page used`() { + this.spring.register(PasswordManagementWithCustomChangePasswordPageConfig::class.java).autowire() + + this.mockMvc.get("/.well-known/change-password") + .andExpect { + status { isFound() } + redirectedUrl("/custom-change-password-page") + } + } + + @EnableWebSecurity + open class PasswordManagementWithCustomChangePasswordPageConfig : WebSecurityConfigurerAdapter() { + override fun configure(http: HttpSecurity) { + http { + passwordManagement { + changePasswordPage = "/custom-change-password-page" + } + } + } + } +} diff --git a/config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-CustomChangePasswordPage.xml b/config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-CustomChangePasswordPage.xml new file mode 100644 index 0000000000..b9f86a6d0f --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-CustomChangePasswordPage.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-DefaultChangePasswordPage.xml b/config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-DefaultChangePasswordPage.xml new file mode 100644 index 0000000000..5ccddc3b36 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/WellKnownChangePasswordBeanDefinitionParserTests-DefaultChangePasswordPage.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/appendix/namespace.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/appendix/namespace.adoc index 3d5b0dca6b..f33d01b906 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/appendix/namespace.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/appendix/namespace.adoc @@ -168,6 +168,7 @@ The default value is true. * <> * <> * <> +* <> * <> * <> * <> @@ -1593,6 +1594,21 @@ Specifies the attribute type. For example, https://axschema.org/contact/email. See your OP's documentation for valid attribute types. +[[nsa-password-management]] +==== +This element configures password management. + +[[nsa-password-management-parents]] +===== Parent Elements of + +* <> + +[[nsa-password-management-attributes]] +===== Attributes + +[[nsa-password-management-change-password-page]] +* **change-password-page** +The change password page. Defaults to "/change-password". [[nsa-port-mappings]] ==== diff --git a/web/src/main/java/org/springframework/security/web/RequestMatcherRedirectFilter.java b/web/src/main/java/org/springframework/security/web/RequestMatcherRedirectFilter.java new file mode 100644 index 0000000000..9971ba9034 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/RequestMatcherRedirectFilter.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Filter that redirects requests that match {@link RequestMatcher} to the specified URL. + * + * @author Evgeniy Cheban + * @since 5.6 + */ +public final class RequestMatcherRedirectFilter extends OncePerRequestFilter { + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private final RequestMatcher requestMatcher; + + private final String redirectUrl; + + /** + * Create and initialize an instance of the filter. + * @param requestMatcher the request matcher + * @param redirectUrl the redirect URL + */ + public RequestMatcherRedirectFilter(RequestMatcher requestMatcher, String redirectUrl) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + Assert.hasText(redirectUrl, "redirectUrl cannot be empty"); + this.requestMatcher = requestMatcher; + this.redirectUrl = redirectUrl; + } + + /** + * {@inheritDoc} + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (this.requestMatcher.matches(request)) { + this.redirectStrategy.sendRedirect(request, response, this.redirectUrl); + } + else { + filterChain.doFilter(request, response); + } + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilter.java b/web/src/main/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilter.java new file mode 100644 index 0000000000..53ead471da --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilter.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server; + +import java.net.URI; + +import reactor.core.publisher.Mono; + +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +/** + * Web filter that redirects requests that match {@link ServerWebExchangeMatcher} to the + * specified URL. + * + * @author Evgeniy Cheban + * @since 5.6 + */ +public final class ExchangeMatcherRedirectWebFilter implements WebFilter { + + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + private final ServerWebExchangeMatcher exchangeMatcher; + + private final URI redirectUri; + + /** + * Create and initialize an instance of the web filter. + * @param exchangeMatcher the exchange matcher + * @param redirectUrl the redirect URL + */ + public ExchangeMatcherRedirectWebFilter(ServerWebExchangeMatcher exchangeMatcher, String redirectUrl) { + Assert.notNull(exchangeMatcher, "exchangeMatcher cannot be null"); + Assert.hasText(redirectUrl, "redirectUrl cannot be empty"); + this.exchangeMatcher = exchangeMatcher; + this.redirectUri = URI.create(redirectUrl); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + // @formatter:off + return this.exchangeMatcher.matches(exchange) + .filter(MatchResult::isMatch) + .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) + .flatMap((result) -> this.redirectStrategy.sendRedirect(exchange, this.redirectUri)); + // @formatter:on + } + +} diff --git a/web/src/test/java/org/springframework/security/web/RequestMatcherRedirectFilterTests.java b/web/src/test/java/org/springframework/security/web/RequestMatcherRedirectFilterTests.java new file mode 100644 index 0000000000..1bb76e243c --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/RequestMatcherRedirectFilterTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web; + +import javax.servlet.FilterChain; + +import org.junit.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link RequestMatcherRedirectFilter}. + * + * @author Evgeniy Cheban + */ +public class RequestMatcherRedirectFilterTests { + + @Test + public void doFilterWhenRequestMatchThenRedirectToSpecifiedUrl() throws Exception { + RequestMatcherRedirectFilter filter = new RequestMatcherRedirectFilter(new AntPathRequestMatcher("/context"), + "/test"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/context"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getRedirectedUrl()).isEqualTo("/test"); + + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenRequestNotMatchThenNextFilter() throws Exception { + RequestMatcherRedirectFilter filter = new RequestMatcherRedirectFilter(new AntPathRequestMatcher("/context"), + "/test"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/test"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(filterChain).doFilter(request, response); + } + + @Test + public void constructWhenRequestMatcherNull() { + assertThatIllegalArgumentException().isThrownBy(() -> new RequestMatcherRedirectFilter(null, "/test")) + .withMessage("requestMatcher cannot be null"); + } + + @Test + public void constructWhenRedirectUrlNull() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RequestMatcherRedirectFilter(new AntPathRequestMatcher("/**"), null)) + .withMessage("redirectUrl cannot be empty"); + } + + @Test + public void constructWhenRedirectUrlEmpty() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RequestMatcherRedirectFilter(new AntPathRequestMatcher("/**"), "")) + .withMessage("redirectUrl cannot be empty"); + } + + @Test + public void constructWhenRedirectUrlBlank() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RequestMatcherRedirectFilter(new AntPathRequestMatcher("/**"), " ")) + .withMessage("redirectUrl cannot be empty"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilterTests.java new file mode 100644 index 0000000000..323e56f96c --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/ExchangeMatcherRedirectWebFilterTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server; + +import java.util.Collections; + +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.server.handler.FilteringWebHandler; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link ExchangeMatcherRedirectWebFilter}. + * + * @author Evgeniy Cheban + */ +public class ExchangeMatcherRedirectWebFilterTests { + + @Test + public void filterWhenRequestMatchThenRedirectToSpecifiedUrl() { + ExchangeMatcherRedirectWebFilter filter = new ExchangeMatcherRedirectWebFilter( + new PathPatternParserServerWebExchangeMatcher("/context"), "/test"); + FilteringWebHandler handler = new FilteringWebHandler((e) -> e.getResponse().setComplete(), + Collections.singletonList(filter)); + + WebTestClient client = WebTestClient.bindToWebHandler(handler).build(); + client.get().uri("/context").exchange().expectStatus().isFound().expectHeader() + .valueEquals(HttpHeaders.LOCATION, "/test"); + } + + @Test + public void filterWhenRequestNotMatchThenNextFilter() { + ExchangeMatcherRedirectWebFilter filter = new ExchangeMatcherRedirectWebFilter( + new PathPatternParserServerWebExchangeMatcher("/context"), "/test"); + FilteringWebHandler handler = new FilteringWebHandler((e) -> e.getResponse().setComplete(), + Collections.singletonList(filter)); + + WebTestClient client = WebTestClient.bindToWebHandler(handler).build(); + client.get().uri("/test").exchange().expectStatus().isOk(); + } + + @Test + public void constructWhenExchangeMatcherNull() { + assertThatIllegalArgumentException().isThrownBy(() -> new ExchangeMatcherRedirectWebFilter(null, "/test")) + .withMessage("exchangeMatcher cannot be null"); + } + + @Test + public void constructWhenRedirectUrlNull() { + assertThatIllegalArgumentException().isThrownBy( + () -> new ExchangeMatcherRedirectWebFilter(new PathPatternParserServerWebExchangeMatcher("/**"), null)) + .withMessage("redirectUrl cannot be empty"); + } + + @Test + public void constructWhenRedirectUrlEmpty() { + assertThatIllegalArgumentException().isThrownBy( + () -> new ExchangeMatcherRedirectWebFilter(new PathPatternParserServerWebExchangeMatcher("/**"), "")) + .withMessage("redirectUrl cannot be empty"); + } + + @Test + public void constructWhenRedirectUrlBlank() { + assertThatIllegalArgumentException().isThrownBy( + () -> new ExchangeMatcherRedirectWebFilter(new PathPatternParserServerWebExchangeMatcher("/**"), " ")) + .withMessage("redirectUrl cannot be empty"); + } + +}