Просмотр исходного кода

Reactive Redirect to Https

This introduces the capability to configure Reactive Spring Security
to upgrade requests to HTTPS

Fixes: gh-5749
Josh Cummings 7 лет назад
Родитель
Сommit
2c982a4168

+ 4 - 0
config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java

@@ -23,6 +23,10 @@ package org.springframework.security.config.web.server;
 public enum SecurityWebFiltersOrder {
 	FIRST(Integer.MIN_VALUE),
 	HTTP_HEADERS_WRITER,
+	/**
+	 * {@link org.springframework.security.web.server.transport.HttpsRedirectWebFilter}
+	 */
+	HTTPS_REDIRECT,
 	/**
 	 * {@link org.springframework.web.cors.reactive.CorsWebFilter}
 	 */

+ 100 - 1
config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Map;
 
 import reactor.core.publisher.Mono;
+import reactor.util.context.Context;
 
 import org.springframework.beans.BeansException;
 import org.springframework.context.ApplicationContext;
@@ -68,6 +69,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtRea
 import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler;
 import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint;
 import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter;
+import org.springframework.security.web.PortMapper;
 import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint;
 import org.springframework.security.web.server.MatcherSecurityWebFilterChain;
 import org.springframework.security.web.server.SecurityWebFilterChain;
@@ -115,11 +117,13 @@ import org.springframework.security.web.server.savedrequest.NoOpServerRequestCac
 import org.springframework.security.web.server.savedrequest.ServerRequestCache;
 import org.springframework.security.web.server.savedrequest.ServerRequestCacheWebFilter;
 import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache;
+import org.springframework.security.web.server.transport.HttpsRedirectWebFilter;
 import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
 import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
 import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcherEntry;
@@ -133,7 +137,6 @@ import org.springframework.web.cors.reactive.DefaultCorsProcessor;
 import org.springframework.web.server.ServerWebExchange;
 import org.springframework.web.server.WebFilter;
 import org.springframework.web.server.WebFilterChain;
-import reactor.util.context.Context;
 
 import static org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint.DelegateEntry;
 import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult.match;
@@ -199,6 +202,8 @@ public class ServerHttpSecurity {
 
 	private AuthorizeExchangeSpec authorizeExchange;
 
+	private HttpsRedirectSpec httpsRedirectSpec;
+
 	private HeaderSpec headers = new HeaderSpec();
 
 	private CsrfSpec csrf = new CsrfSpec();
@@ -286,6 +291,42 @@ public class ServerHttpSecurity {
 		return this;
 	}
 
+	/**
+	 * Configures HTTPS redirection rules. If the default is used:
+	 *
+	 * <pre class="code">
+	 *  &#064;Bean
+	 * 	public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 * 	    http
+	 * 	        // ...
+	 * 	        .redirectToHttps();
+	 * 	    return http.build();
+	 * 	}
+	 * </pre>
+	 *
+	 * Then all non-HTTPS requests will be redirected to HTTPS.
+	 *
+	 * Typically, all requests should be HTTPS; however, the focus for redirection can also be narrowed:
+	 *
+	 * <pre class="code">
+	 *  &#064;Bean
+	 * 	public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 * 	    http
+	 * 	        // ...
+	 * 	        .redirectToHttps()
+	 * 	            .httpsRedirectWhen(serverWebExchange ->
+	 * 	            	serverWebExchange.getRequest().getHeaders().containsKey("X-Requires-Https"))
+	 * 	    return http.build();
+	 * 	}
+	 * </pre>
+	 *
+	 * @return the {@link HttpsRedirectSpec} to customize
+	 */
+	public HttpsRedirectSpec redirectToHttps() {
+		this.httpsRedirectSpec = new HttpsRedirectSpec();
+		return this.httpsRedirectSpec;
+	}
+
 	/**
 	 * Configures <a href="https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet">CSRF Protection</a>
 	 * which is enabled by default. You can disable it using:
@@ -1044,6 +1085,9 @@ public class ServerHttpSecurity {
 		if (securityContextRepositoryWebFilter != null) {
 			this.webFilters.add(securityContextRepositoryWebFilter);
 		}
+		if (this.httpsRedirectSpec != null) {
+			this.httpsRedirectSpec.configure(this);
+		}
 		if (this.csrf != null) {
 			this.csrf.configure(this);
 		}
@@ -1277,6 +1321,61 @@ public class ServerHttpSecurity {
 		}
 	}
 
+	/**
+	 * Configures HTTPS redirection rules
+	 *
+	 * @author Josh Cummings
+	 * @since 5.1
+	 * @see #redirectToHttps()
+	 */
+	public class HttpsRedirectSpec {
+		private ServerWebExchangeMatcher serverWebExchangeMatcher;
+		private PortMapper portMapper;
+
+		/**
+		 * Configures when this filter should redirect to https
+		 *
+		 * By default, the filter will redirect whenever an exchange's scheme is not https
+		 *
+		 * @param matchers the list of conditions that, when any are met, the filter should redirect to https
+		 * @return the {@link HttpsRedirectSpec} for additional configuration
+		 */
+		public HttpsRedirectSpec httpsRedirectWhen(ServerWebExchangeMatcher... matchers) {
+			this.serverWebExchangeMatcher = new OrServerWebExchangeMatcher(matchers);
+			return this;
+		}
+
+		/**
+		 * Configures a custom HTTPS port to redirect to
+		 *
+		 * @param portMapper the {@link PortMapper} to use
+		 * @return the {@link HttpsRedirectSpec} for additional configuration
+		 */
+		public HttpsRedirectSpec portMapper(PortMapper portMapper) {
+			this.portMapper = portMapper;
+			return this;
+		}
+
+		protected void configure(ServerHttpSecurity http) {
+			HttpsRedirectWebFilter httpsRedirectWebFilter = new HttpsRedirectWebFilter();
+			if (this.serverWebExchangeMatcher != null) {
+				httpsRedirectWebFilter.setRequiresHttpsRedirectMatcher(this.serverWebExchangeMatcher);
+			}
+			if (this.portMapper != null) {
+				httpsRedirectWebFilter.setPortMapper(this.portMapper);
+			}
+			http.addFilterAt(httpsRedirectWebFilter, SecurityWebFiltersOrder.HTTPS_REDIRECT);
+		}
+
+		/**
+		 * Allows method chaining to continue configuring the {@link ServerHttpSecurity}
+		 * @return the {@link ServerHttpSecurity} to continue configuring
+		 */
+		public ServerHttpSecurity and() {
+			return ServerHttpSecurity.this;
+		}
+	}
+
 	/**
 	 * Configures <a href="https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet">CSRF Protection</a>
 	 *

+ 152 - 0
config/src/test/java/org/springframework/security/config/web/server/HttpsRedirectSpecTests.java

@@ -0,0 +1,152 @@
+/*
+ * Copyright 2002-2018 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.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.PortMapper;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.web.reactive.config.EnableWebFlux;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link HttpsRedirectSpecTests}
+ *
+ * @author Josh Cummings
+ */
+public class HttpsRedirectSpecTests {
+	@Rule
+	public final SpringTestRule spring = new SpringTestRule();
+
+	WebTestClient client;
+
+	@Autowired
+	public void setApplicationContext(ApplicationContext context) {
+		this.client = WebTestClient.bindToApplicationContext(context).build();
+	}
+
+	@Test
+	public void getWhenSecureThenDoesNotRedirect() {
+		this.spring.register(RedirectToHttpConfig.class).autowire();
+
+		this.client.get()
+				.uri("https://localhost")
+				.exchange()
+				.expectStatus().isNotFound();
+	}
+
+	@Test
+	public void getWhenInsecureThenRespondsWithRedirectToSecure() {
+		this.spring.register(RedirectToHttpConfig.class).autowire();
+
+		this.client.get()
+				.uri("http://localhost")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().valueEquals(HttpHeaders.LOCATION, "https://localhost");
+	}
+
+	@Test
+	public void getWhenInsecureAndPathRequiresTransportSecurityThenRedirects() {
+		this.spring.register(SometimesRedirectToHttpsConfig.class).autowire();
+
+		this.client.get()
+				.uri("http://localhost:8080")
+				.exchange()
+				.expectStatus().isNotFound();
+
+		this.client.get()
+				.uri("http://localhost:8080/secure")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().valueEquals(HttpHeaders.LOCATION, "https://localhost:8443/secure");
+	}
+
+	@Test
+	public void getWhenInsecureAndUsingCustomPortMapperThenRespondsWithRedirectToSecurePort() {
+		this.spring.register(RedirectToHttpsViaCustomPortsConfig.class).autowire();
+
+		PortMapper portMapper = this.spring.getContext().getBean(PortMapper.class);
+		when(portMapper.lookupHttpsPort(4080)).thenReturn(4443);
+
+		this.client.get()
+				.uri("http://localhost:4080")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().valueEquals(HttpHeaders.LOCATION, "https://localhost:4443");
+	}
+
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	static class RedirectToHttpConfig {
+		@Bean
+		SecurityWebFilterChain springSecurity(ServerHttpSecurity http) {
+			// @formatter:off
+			http
+				.redirectToHttps();
+			// @formatter:on
+
+			return http.build();
+		}
+	}
+
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	static class SometimesRedirectToHttpsConfig {
+		@Bean
+		SecurityWebFilterChain springSecurity(ServerHttpSecurity http) {
+			// @formatter:off
+			http
+				.redirectToHttps()
+					.httpsRedirectWhen(new PathPatternParserServerWebExchangeMatcher("/secure"));
+			// @formatter:on
+
+			return http.build();
+		}
+	}
+
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	static class RedirectToHttpsViaCustomPortsConfig {
+		@Bean
+		SecurityWebFilterChain springSecurity(ServerHttpSecurity http) {
+			// @formatter:off
+			http
+				.redirectToHttps()
+					.portMapper(portMapper());
+			// @formatter:on
+
+			return http.build();
+		}
+
+		@Bean
+		public PortMapper portMapper() {
+			return mock(PortMapper.class);
+		}
+	}
+}

+ 110 - 0
web/src/main/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilter.java

@@ -0,0 +1,110 @@
+/*
+ * Copyright 2002-2018 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.web.server.transport;
+
+import java.net.URI;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.web.PortMapper;
+import org.springframework.security.web.PortMapperImpl;
+import org.springframework.security.web.server.DefaultServerRedirectStrategy;
+import org.springframework.security.web.server.ServerRedirectStrategy;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.util.Assert;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.anyExchange;
+
+/**
+ * Redirects any non-HTTPS request to its HTTPS equivalent.
+ *
+ * Can be configured to use a {@link ServerWebExchangeMatcher} to narrow which requests get redirected.
+ *
+ * Can also be configured for custom ports using {@link PortMapper}.
+ *
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public final class HttpsRedirectWebFilter implements WebFilter {
+	private PortMapper portMapper = new PortMapperImpl();
+
+	private ServerWebExchangeMatcher requiresHttpsRedirectMatcher = anyExchange();
+
+	private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+		return Mono.just(exchange)
+				.filter(this::isInsecure)
+				.flatMap(this.requiresHttpsRedirectMatcher::matches)
+				.filter(matchResult -> matchResult.isMatch())
+				.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
+				.map(matchResult -> createRedirectUri(exchange))
+				.flatMap(uri -> this.redirectStrategy.sendRedirect(exchange, uri));
+	}
+
+	/**
+	 * Use this {@link PortMapper} for mapping custom ports
+	 *
+	 * @param portMapper the {@link PortMapper} to use
+	 */
+	public void setPortMapper(PortMapper portMapper) {
+		Assert.notNull(portMapper, "portMapper cannot be null");
+		this.portMapper = portMapper;
+	}
+
+	/**
+	 * Use this {@link ServerWebExchangeMatcher} to narrow which requests are redirected to HTTPS.
+	 *
+	 * The filter already first checks for HTTPS in the uri scheme, so it is not necessary
+	 * to include that check in this matcher.
+	 *
+	 * @param requiresHttpsRedirectMatcher the {@link ServerWebExchangeMatcher} to use
+	 */
+	public void setRequiresHttpsRedirectMatcher
+			(ServerWebExchangeMatcher requiresHttpsRedirectMatcher) {
+
+		Assert.notNull(requiresHttpsRedirectMatcher,
+				"requiresHttpsRedirectMatcher cannot be null");
+		this.requiresHttpsRedirectMatcher = requiresHttpsRedirectMatcher;
+	}
+
+	private Boolean isInsecure(ServerWebExchange exchange) {
+		return !"https".equals(exchange.getRequest().getURI().getScheme());
+	}
+
+	private URI createRedirectUri(ServerWebExchange exchange) {
+		int port = exchange.getRequest().getURI().getPort();
+
+		UriComponentsBuilder builder =
+				UriComponentsBuilder.fromUri(exchange.getRequest().getURI());
+
+		if (port > 0) {
+			port = this.portMapper.lookupHttpsPort(port);
+			builder.port(port);
+		}
+
+		return builder.scheme("https").build().toUri();
+	}
+}

+ 149 - 0
web/src/test/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilterTests.java

@@ -0,0 +1,149 @@
+/*
+ * Copyright 2002-2018 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.web.server.transport;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.web.PortMapper;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilterChain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link HttpsRedirectWebFilter}
+ *
+ * @author Josh Cummings
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class HttpsRedirectWebFilterTests {
+	HttpsRedirectWebFilter filter;
+
+	@Mock
+	WebFilterChain chain;
+
+	@Before
+	public void configureFilter() {
+		this.filter = new HttpsRedirectWebFilter();
+		when(this.chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
+	}
+
+	@Test
+	public void filterWhenExchangeIsInsecureThenRedirects() {
+		ServerWebExchange exchange = get("http://localhost");
+		this.filter.filter(exchange, this.chain).block();
+		assertThat(statusCode(exchange)).isEqualTo(302);
+		assertThat(redirectedUrl(exchange)).isEqualTo("https://localhost");
+	}
+
+	@Test
+	public void filterWhenExchangeIsSecureThenNoRedirect() {
+		ServerWebExchange exchange = get("https://localhost");
+		this.filter.filter(exchange, this.chain).block();
+		assertThat(exchange.getResponse().getStatusCode()).isNull();
+	}
+
+	@Test
+	public void filterWhenExchangeMismatchesThenNoRedirect() {
+		ServerWebExchangeMatcher matcher = mock(ServerWebExchangeMatcher.class);
+		when(matcher.matches(any(ServerWebExchange.class)))
+				.thenReturn(ServerWebExchangeMatcher.MatchResult.notMatch());
+		this.filter.setRequiresHttpsRedirectMatcher(matcher);
+
+		ServerWebExchange exchange = get("http://localhost:8080");
+		this.filter.filter(exchange, this.chain).block();
+		assertThat(exchange.getResponse().getStatusCode()).isNull();
+	}
+
+	@Test
+	public void filterWhenExchangeMatchesAndRequestIsInsecureThenRedirects() {
+		ServerWebExchangeMatcher matcher = mock(ServerWebExchangeMatcher.class);
+		when(matcher.matches(any(ServerWebExchange.class)))
+				.thenReturn(ServerWebExchangeMatcher.MatchResult.match());
+		this.filter.setRequiresHttpsRedirectMatcher(matcher);
+
+		ServerWebExchange exchange = get("http://localhost:8080");
+		this.filter.filter(exchange, this.chain).block();
+		assertThat(statusCode(exchange)).isEqualTo(302);
+		assertThat(redirectedUrl(exchange)).isEqualTo("https://localhost:8443");
+
+		verify(matcher).matches(any(ServerWebExchange.class));
+	}
+
+	@Test
+	public void filterWhenRequestIsInsecureThenPortMapperRemapsPort() {
+		PortMapper portMapper = mock(PortMapper.class);
+		when(portMapper.lookupHttpsPort(314)).thenReturn(159);
+		this.filter.setPortMapper(portMapper);
+
+		ServerWebExchange exchange = get("http://localhost:314");
+		this.filter.filter(exchange, this.chain).block();
+		assertThat(statusCode(exchange)).isEqualTo(302);
+		assertThat(redirectedUrl(exchange)).isEqualTo("https://localhost:159");
+
+		verify(portMapper).lookupHttpsPort(314);
+	}
+
+
+	@Test
+	public void filterWhenInsecureRequestHasAPathThenRedirects() {
+		ServerWebExchange exchange = get("http://localhost:8080/path/page.html?query=string");
+		this.filter.filter(exchange, this.chain).block();
+		assertThat(statusCode(exchange)).isEqualTo(302);
+		assertThat(redirectedUrl(exchange)).isEqualTo("https://localhost:8443/path/page.html?query=string");
+	}
+
+	@Test
+	public void setRequiresTransportSecurityMatcherWhenSetWithNullValueThenThrowsIllegalArgument() {
+		assertThatCode(() -> this.filter.setRequiresHttpsRedirectMatcher(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void setPortMapperWhenSetWithNullValueThenThrowsIllegalArgument() {
+		assertThatCode(() -> this.filter.setPortMapper(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	private String redirectedUrl(ServerWebExchange exchange) {
+		return exchange.getResponse().getHeaders().get(HttpHeaders.LOCATION)
+				.iterator().next();
+	}
+
+	private int statusCode(ServerWebExchange exchange) {
+		return exchange.getResponse().getStatusCode().value();
+	}
+
+	private ServerWebExchange get(String uri) {
+		return MockServerWebExchange.from(
+				MockServerHttpRequest.get(uri).build());
+	}
+}