Browse Source

Add FormLogin Configuration

Fixes gh-4537
Rob Winch 8 years ago
parent
commit
d93c774691

+ 1 - 0
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;

+ 156 - 8
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<DelegateEntry> 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

+ 48 - 2
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.<Mono<Principal>>get(Authentication.class))
+						.flatMap( principal -> exchange.getResponse()
+							.writeWith(Mono.just(toDataBuffer(principal.getName()))))
+			)
+			.build();
+
+
+			MultiValueMap<String, String> 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

+ 255 - 0
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> T submit(Class<T> 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> T submit(Class<T> 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> T to(WebDriver driver, Class<T> page) {
+			driver.get("http://localhost/");
+			return PageFactory.initElements(driver, page);
+		}
+	}
+
+	@Controller
+	public static class CustomLoginPageController {
+		@ResponseBody
+		@GetMapping("/login")
+		public String login() {
+			return "<!DOCTYPE html>\n"
+				+ "<html lang=\"en\">\n"
+				+ "  <head>\n"
+				+ "    <meta charset=\"utf-8\">\n"
+				+ "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n"
+				+ "    <meta name=\"description\" content=\"\">\n"
+				+ "    <meta name=\"author\" content=\"\">\n"
+				+ "    <title>Custom Log In Page</title>\n"
+				+ "  </head>\n"
+				+ "  <body>\n"
+				+ "     <div>\n"
+				+ "      <form method=\"post\" action=\"/login\">\n"
+				+ "        <h2>Please sign in</h2>\n"
+				+ "        <p>\n"
+				+ "          <label for=\"username\">Username</label>\n"
+				+ "          <input type=\"text\" id=\"username\" name=\"username\" placeholder=\"Username\" required autofocus>\n"
+				+ "        </p>\n"
+				+ "        <p>\n"
+				+ "          <label for=\"password\" class=\"sr-only\">Password</label>\n"
+				+ "          <input type=\"password\" id=\"password\" name=\"password\" placeholder=\"Password\" required>\n"
+				+ "        </p>\n"
+				+ "        <button type=\"submit\">Sign in</button>\n"
+				+ "      </form>\n"
+				+ "    </div>\n"
+				+ "  </body>\n"
+				+ "</html>";
+		}
+
+	}
+}

+ 1 - 1
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() {