From 2c982a4168ad01de4ad604ee81e461d8bd7da91f Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 30 Aug 2018 10:35:13 -0600 Subject: [PATCH] Reactive Redirect to Https This introduces the capability to configure Reactive Spring Security to upgrade requests to HTTPS Fixes: gh-5749 --- .../web/server/SecurityWebFiltersOrder.java | 4 + .../config/web/server/ServerHttpSecurity.java | 101 +++++++++++- .../web/server/HttpsRedirectSpecTests.java | 152 ++++++++++++++++++ .../transport/HttpsRedirectWebFilter.java | 110 +++++++++++++ .../HttpsRedirectWebFilterTests.java | 149 +++++++++++++++++ 5 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 config/src/test/java/org/springframework/security/config/web/server/HttpsRedirectSpecTests.java create mode 100644 web/src/main/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilter.java create mode 100644 web/src/test/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilterTests.java diff --git a/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java b/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java index 3c3d8acf9c..a272ae7363 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java +++ b/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} */ 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 f344332186..8f2b790ddd 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 @@ -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: + * + *
+	 *  @Bean
+	 * 	public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 * 	    http
+	 * 	        // ...
+	 * 	        .redirectToHttps();
+	 * 	    return http.build();
+	 * 	}
+	 * 
+ * + * 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: + * + *
+	 *  @Bean
+	 * 	public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 * 	    http
+	 * 	        // ...
+	 * 	        .redirectToHttps()
+	 * 	            .httpsRedirectWhen(serverWebExchange ->
+	 * 	            	serverWebExchange.getRequest().getHeaders().containsKey("X-Requires-Https"))
+	 * 	    return http.build();
+	 * 	}
+	 * 
+ * + * @return the {@link HttpsRedirectSpec} to customize + */ + public HttpsRedirectSpec redirectToHttps() { + this.httpsRedirectSpec = new HttpsRedirectSpec(); + return this.httpsRedirectSpec; + } + /** * Configures CSRF Protection * 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 CSRF Protection * diff --git a/config/src/test/java/org/springframework/security/config/web/server/HttpsRedirectSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HttpsRedirectSpecTests.java new file mode 100644 index 0000000000..bba6846af7 --- /dev/null +++ b/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); + } + } +} diff --git a/web/src/main/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilter.java b/web/src/main/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilter.java new file mode 100644 index 0000000000..059b423085 --- /dev/null +++ b/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 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(); + } +} diff --git a/web/src/test/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/transport/HttpsRedirectWebFilterTests.java new file mode 100644 index 0000000000..a8a376754f --- /dev/null +++ b/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()); + } +}