From d93c774691e9119c90a4343aedfee7eb77766628 Mon Sep 17 00:00:00 2001 From: Rob Winch Date: Tue, 12 Sep 2017 16:23:35 -0500 Subject: [PATCH] Add FormLogin Configuration Fixes gh-4537 --- .../reactive/HttpSecurityConfiguration.java | 1 + .../config/web/server/HttpSecurity.java | 164 ++++++++++- .../reactive/EnableWebFluxSecurityTests.java | 50 +++- .../config/web/server/FormLoginTests.java | 255 ++++++++++++++++++ .../reactive/server/WebTestClientBuilder.java | 2 +- 5 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 config/src/test/java/org/springframework/security/config/web/server/FormLoginTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/HttpSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/HttpSecurityConfiguration.java index af7c55518a..941c39aff2 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/HttpSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/HttpSecurityConfiguration.java @@ -69,6 +69,7 @@ public class HttpSecurityConfiguration implements WebFluxConfigurer { public HttpSecurity httpSecurity() { HttpSecurity http = http(); http.httpBasic(); + http.formLogin(); http.authenticationManager(authenticationManager()); http.securityContextRepository(new WebSessionSecurityContextRepository()); return http; diff --git a/config/src/main/java/org/springframework/security/config/web/server/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/HttpSecurity.java index 8acec59f90..81a7caf1f8 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/HttpSecurity.java @@ -18,28 +18,41 @@ package org.springframework.security.config.web.server; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; -import java.util.Optional; +import org.springframework.http.MediaType; +import org.springframework.security.web.server.DelegatingAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authorization.AuthenticatedAuthorizationManager; import org.springframework.security.authorization.AuthorityAuthorizationManager; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.web.server.AuthenticationEntryPoint; +import org.springframework.security.web.server.FormLoginAuthenticationConverter; import org.springframework.security.web.server.HttpBasicAuthenticationConverter; import org.springframework.security.web.server.MatcherSecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.AuthenticationEntryPointFailureHandler; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.authentication.RedirectAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.RedirectAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.www.HttpBasicAuthenticationEntryPoint; import org.springframework.security.web.server.authorization.AuthorizationContext; import org.springframework.security.web.server.authorization.AuthorizationWebFilter; import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager; -import org.springframework.security.web.server.context.AuthenticationReactorContextFilter; -import org.springframework.security.web.server.context.SecurityContextRepositoryWebFilter; import org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter; +import org.springframework.security.web.server.context.AuthenticationReactorContextFilter; import org.springframework.security.web.server.context.SecurityContextRepository; +import org.springframework.security.web.server.context.SecurityContextRepositoryWebFilter; import org.springframework.security.web.server.context.ServerWebExchangeAttributeSecurityContextRepository; +import org.springframework.security.web.server.context.WebSessionSecurityContextRepository; import org.springframework.security.web.server.header.CacheControlHttpHeadersWriter; import org.springframework.security.web.server.header.CompositeHttpHeadersWriter; import org.springframework.security.web.server.header.ContentTypeOptionsHttpHeadersWriter; @@ -48,12 +61,14 @@ import org.springframework.security.web.server.header.HttpHeadersWriter; import org.springframework.security.web.server.header.StrictTransportSecurityHttpHeadersWriter; import org.springframework.security.web.server.header.XFrameOptionsHttpHeadersWriter; import org.springframework.security.web.server.header.XXssProtectionHttpHeadersWriter; +import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcherEntry; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.util.Assert; import org.springframework.web.server.WebFilter; -import reactor.core.publisher.Mono; + +import static org.springframework.security.web.server.DelegatingAuthenticationEntryPoint.*; /** * @author Rob Winch @@ -66,10 +81,16 @@ public class HttpSecurity { private HeaderBuilder headers = new HeaderBuilder(); private HttpBasicBuilder httpBasic; + private FormLoginBuilder formLogin; + private ReactiveAuthenticationManager authenticationManager; private SecurityContextRepository securityContextRepository; + private AuthenticationEntryPoint authenticationEntryPoint; + + private List defaultEntryPoints = new ArrayList<>(); + /** * The ServerExchangeMatcher that determines which requests apply to this HttpSecurity instance. * @@ -103,6 +124,13 @@ public class HttpSecurity { return this.httpBasic; } + public FormLoginBuilder formLogin() { + if(this.formLogin == null) { + this.formLogin = new FormLoginBuilder(); + } + return this.formLogin; + } + public HeaderBuilder headers() { return this.headers; } @@ -135,21 +163,56 @@ public class HttpSecurity { } filters.add(this.httpBasic.build()); } + if(this.formLogin != null) { + this.formLogin.authenticationManager(this.authenticationManager); + if(this.securityContextRepository != null) { + this.formLogin.securityContextRepository(this.securityContextRepository); + } + if(this.formLogin.authenticationEntryPoint == null) { + filters.add(new LoginPageGeneratingWebFilter()); + } + filters.add(this.formLogin.build()); + } filters.add(new AuthenticationReactorContextFilter()); if(this.authorizeExchangeBuilder != null) { - filters.add(new ExceptionTranslationWebFilter()); + AuthenticationEntryPoint authenticationEntryPoint = getAuthenticationEntryPoint(); + ExceptionTranslationWebFilter exceptionTranslationWebFilter = new ExceptionTranslationWebFilter(); + if(authenticationEntryPoint != null) { + exceptionTranslationWebFilter.setAuthenticationEntryPoint(authenticationEntryPoint); + } + filters.add(exceptionTranslationWebFilter); filters.add(this.authorizeExchangeBuilder.build()); } return new MatcherSecurityWebFilterChain(getSecurityMatcher(), filters); } + private AuthenticationEntryPoint getAuthenticationEntryPoint() { + if(this.authenticationEntryPoint != null || this.defaultEntryPoints.isEmpty()) { + return this.authenticationEntryPoint; + } + if(this.defaultEntryPoints.size() == 1) { + return this.defaultEntryPoints.get(0).getEntryPoint(); + } + DelegatingAuthenticationEntryPoint result = new DelegatingAuthenticationEntryPoint(this.defaultEntryPoints); + result.setDefaultEntryPoint(this.defaultEntryPoints.get(this.defaultEntryPoints.size() - 1).getEntryPoint()); + return result; + } + public static HttpSecurity http() { return new HttpSecurity(); } private SecurityContextRepositoryWebFilter securityContextRepositoryWebFilter() { - return this.securityContextRepository == null ? null : - new SecurityContextRepositoryWebFilter(this.securityContextRepository); + SecurityContextRepository respository = getSecurityContextRepository(); + return respository == null ? null : + new SecurityContextRepositoryWebFilter(respository); + } + + private SecurityContextRepository getSecurityContextRepository() { + if(this.securityContextRepository == null && this.formLogin != null) { + this.securityContextRepository = this.formLogin.securityContextRepository; + } + return this.securityContextRepository; } private HttpSecurity() {} @@ -256,9 +319,16 @@ public class HttpSecurity { } protected AuthenticationWebFilter build() { + MediaTypeServerWebExchangeMatcher restMatcher = new MediaTypeServerWebExchangeMatcher( + MediaType.APPLICATION_ATOM_XML, + MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON, + MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_XML, + MediaType.MULTIPART_FORM_DATA, MediaType.TEXT_XML); + restMatcher.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); + HttpSecurity.this.defaultEntryPoints.add(new DelegateEntry(restMatcher, this.entryPoint)); AuthenticationWebFilter authenticationFilter = new AuthenticationWebFilter( this.authenticationManager); - authenticationFilter.setEntryPoint(this.entryPoint); + authenticationFilter.setAuthenticationFailureHandler(new AuthenticationEntryPointFailureHandler(this.entryPoint)); authenticationFilter.setAuthenticationConverter(new HttpBasicAuthenticationConverter()); if(this.securityContextRepository != null) { authenticationFilter.setSecurityContextRepository(this.securityContextRepository); @@ -269,6 +339,84 @@ public class HttpSecurity { private HttpBasicBuilder() {} } + /** + * @author Rob Winch + * @since 5.0 + */ + public class FormLoginBuilder { + private ReactiveAuthenticationManager authenticationManager; + + private SecurityContextRepository securityContextRepository = new WebSessionSecurityContextRepository(); + + private AuthenticationEntryPoint authenticationEntryPoint; + + private ServerWebExchangeMatcher requiresAuthenticationMatcher; + + private AuthenticationFailureHandler authenticationFailureHandler; + + public FormLoginBuilder authenticationManager(ReactiveAuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + return this; + } + + public FormLoginBuilder loginPage(String loginPage) { + this.authenticationEntryPoint = new RedirectAuthenticationEntryPoint(loginPage); + this.requiresAuthenticationMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, loginPage); + this.authenticationFailureHandler = new AuthenticationEntryPointFailureHandler(new RedirectAuthenticationEntryPoint(loginPage + "?error")); + return this; + } + + public FormLoginBuilder authenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint) { + this.authenticationEntryPoint = authenticationEntryPoint; + return this; + } + + public FormLoginBuilder requiresAuthenticationMatcher(ServerWebExchangeMatcher requiresAuthenticationMatcher) { + this.requiresAuthenticationMatcher = requiresAuthenticationMatcher; + return this; + } + + public FormLoginBuilder authenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { + this.authenticationFailureHandler = authenticationFailureHandler; + return this; + } + + public FormLoginBuilder securityContextRepository(SecurityContextRepository securityContextRepository) { + this.securityContextRepository = securityContextRepository; + return this; + } + + public HttpSecurity and() { + return HttpSecurity.this; + } + + public HttpSecurity disable() { + HttpSecurity.this.formLogin = null; + return HttpSecurity.this; + } + + protected AuthenticationWebFilter build() { + if(this.authenticationEntryPoint == null) { + loginPage("/login"); + } + MediaTypeServerWebExchangeMatcher htmlMatcher = new MediaTypeServerWebExchangeMatcher( + MediaType.TEXT_HTML); + htmlMatcher.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); + HttpSecurity.this.defaultEntryPoints.add(0, new DelegateEntry(htmlMatcher, this.authenticationEntryPoint)); + AuthenticationWebFilter authenticationFilter = new AuthenticationWebFilter( + this.authenticationManager); + authenticationFilter.setRequiresAuthenticationMatcher(this.requiresAuthenticationMatcher); + authenticationFilter.setAuthenticationFailureHandler(this.authenticationFailureHandler); + authenticationFilter.setAuthenticationConverter(new FormLoginAuthenticationConverter()); + authenticationFilter.setAuthenticationSuccessHandler(new RedirectAuthenticationSuccessHandler("/")); + authenticationFilter.setSecurityContextRepository(this.securityContextRepository); + return authenticationFilter; + } + + private FormLoginBuilder() { + } + } + /** * @author Rob Winch * @since 5.0 diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/reactive/EnableWebFluxSecurityTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/reactive/EnableWebFluxSecurityTests.java index bf4c913b3d..02632236c1 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/reactive/EnableWebFluxSecurityTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/reactive/EnableWebFluxSecurityTests.java @@ -41,6 +41,9 @@ import org.springframework.security.web.server.WebFilterChainFilter; import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; @@ -144,8 +147,8 @@ public class EnableWebFluxSecurityTests { .flatMap( principal -> exchange.getResponse() .writeWith(Mono.just(toDataBuffer(principal.getName())))) ) - .filter(basicAuthentication()) - .build(); + .filter(basicAuthentication()) + .build(); client .get() @@ -174,6 +177,49 @@ public class EnableWebFluxSecurityTests { } } + + @RunWith(SpringRunner.class) + public static class FormLoginTests { + @Autowired + WebFilterChainFilter springSecurityFilterChain; + @Test + public void formLoginWorks() { + WebTestClient client = WebTestClientBuilder.bindToWebFilters( + springSecurityFilterChain, + (exchange,chain) -> + Mono.subscriberContext() + .flatMap( c -> c.>get(Authentication.class)) + .flatMap( principal -> exchange.getResponse() + .writeWith(Mono.just(toDataBuffer(principal.getName())))) + ) + .build(); + + + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + client + .post() + .uri("/login") + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectStatus().is3xxRedirection() + .expectHeader().valueMatches("Location", "/"); + } + + @EnableWebFluxSecurity + static class Config { + @Bean + public UserDetailsRepository userDetailsRepository() { + return new MapUserDetailsRepository(User.withUsername("user") + .password("password") + .roles("USER") + .build() + ); + } + } + } + @RunWith(SpringRunner.class) public static class MultiHttpSecurity { @Autowired diff --git a/config/src/test/java/org/springframework/security/config/web/server/FormLoginTests.java b/config/src/test/java/org/springframework/security/config/web/server/FormLoginTests.java new file mode 100644 index 0000000000..e5ae8355b5 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/server/FormLoginTests.java @@ -0,0 +1,255 @@ +/* + * + * * Copyright 2002-2017 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 + * * + * * http://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.junit.Test; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.UserDetailsRepositoryAuthenticationManager; +import org.springframework.security.core.userdetails.MapUserDetailsRepository; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.htmlunit.server.WebTestClientHtmlUnitDriverBuilder; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.WebFilterChainFilter; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class FormLoginTests { + private UserDetails user = User.withUsername("user").password("password").roles("USER").build(); + private HttpSecurity http = HttpSecurity.http(); + + ReactiveAuthenticationManager manager = new UserDetailsRepositoryAuthenticationManager(new MapUserDetailsRepository(this.user)); + + @Test + public void defaultLoginPage() { + SecurityWebFilterChain securityWebFilter = this.http + .authenticationManager(this.manager) + .authorizeExchange() + .anyExchange().authenticated() + .and() + .formLogin().and() + .build(); + + WebTestClient webTestClient = WebTestClientBuilder + .bindToWebFilters(securityWebFilter) + .build(); + + WebDriver driver = WebTestClientHtmlUnitDriverBuilder + .webTestClientSetup(webTestClient) + .build(); + + DefaultLoginPage loginPage = HomePage.to(driver, DefaultLoginPage.class); + + HomePage homePage = loginPage.loginForm() + .username("user") + .password("password") + .submit(HomePage.class); + + homePage.assertAt(); + } + + @Test + public void customLoginPage() { + SecurityWebFilterChain securityWebFilter = this.http + .authenticationManager(this.manager) + .authorizeExchange() + .pathMatchers("/login").permitAll() + .anyExchange().authenticated() + .and() + .formLogin() + .loginPage("/login") + .and() + .build(); + + WebTestClient webTestClient = WebTestClient + .bindToController(new CustomLoginPageController(), new WebTestClientBuilder.Http200RestController()) + .webFilter(WebFilterChainFilter.fromSecurityWebFilterChains(securityWebFilter)) + .build(); + + WebDriver driver = WebTestClientHtmlUnitDriverBuilder + .webTestClientSetup(webTestClient) + .build(); + + CustomLoginPage loginPage = HomePage.to(driver, CustomLoginPage.class) + .assertAt(); + + HomePage homePage = loginPage.loginForm() + .username("user") + .password("password") + .submit(HomePage.class); + + homePage.assertAt(); + } + + public static class CustomLoginPage { + + private WebDriver driver; + + private LoginForm loginForm; + + public CustomLoginPage(WebDriver webDriver) { + this.driver = webDriver; + this.loginForm = PageFactory.initElements(webDriver, LoginForm.class); + } + + public CustomLoginPage assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Custom Log In Page"); + return this; + } + + public LoginForm loginForm() { + return this.loginForm; + } + + public static class LoginForm { + private WebDriver driver; + private WebElement username; + private WebElement password; + @FindBy(css = "button[type=submit]") + private WebElement submit; + + public LoginForm(WebDriver driver) { + this.driver = driver; + } + + public LoginForm username(String username) { + this.username.sendKeys(username); + return this; + } + + public LoginForm password(String password) { + this.password.sendKeys(password); + return this; + } + + public T submit(Class page) { + this.submit.click(); + return PageFactory.initElements(this.driver, page); + } + } + } + + public static class DefaultLoginPage { + + private WebDriver driver; + + private LoginForm loginForm; + + public DefaultLoginPage(WebDriver webDriver) { + this.driver = webDriver; + this.loginForm = PageFactory.initElements(webDriver, LoginForm.class); + } + + public LoginForm loginForm() { + return this.loginForm; + } + + public static class LoginForm { + private WebDriver driver; + private WebElement username; + private WebElement password; + @FindBy(css = "button[type=submit]") + private WebElement submit; + + public LoginForm(WebDriver driver) { + this.driver = driver; + } + + public LoginForm username(String username) { + this.username.sendKeys(username); + return this; + } + + public LoginForm password(String password) { + this.password.sendKeys(password); + return this; + } + + public T submit(Class page) { + this.submit.click(); + return PageFactory.initElements(this.driver, page); + } + } + } + + public static class HomePage { + private WebDriver driver; + + public HomePage(WebDriver driver) { + this.driver = driver; + } + + public void assertAt() { + assertThat(this.driver.getPageSource()).contains("ok"); + } + + static T to(WebDriver driver, Class page) { + driver.get("http://localhost/"); + return PageFactory.initElements(driver, page); + } + } + + @Controller + public static class CustomLoginPageController { + @ResponseBody + @GetMapping("/login") + public String login() { + return "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " Custom Log In Page\n" + + " \n" + + " \n" + + "
\n" + + "
\n" + + "

Please sign in

\n" + + "

\n" + + " \n" + + " \n" + + "

\n" + + "

\n" + + " \n" + + " \n" + + "

\n" + + " \n" + + "
\n" + + "
\n" + + " \n" + + ""; + } + + } +} diff --git a/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestClientBuilder.java b/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestClientBuilder.java index c92b16e85e..dbf6122484 100644 --- a/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestClientBuilder.java +++ b/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestClientBuilder.java @@ -47,7 +47,7 @@ public class WebTestClientBuilder { } @RestController - static class Http200RestController { + public static class Http200RestController { @RequestMapping("/**") @ResponseStatus(HttpStatus.OK) public String ok() {