From ae532c080cbd594af7eeeee99742152225de4087 Mon Sep 17 00:00:00 2001 From: Eleftheria Stein Date: Thu, 5 Mar 2020 15:36:47 -0500 Subject: [PATCH] Add server request cache that uses cookie Fixes: gh-8033 --- .../CookieServerRequestCache.java | 131 ++++++++++++++++ .../CookieServerRequestCacheTests.java | 145 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java create mode 100644 web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java diff --git a/web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java b/web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java new file mode 100644 index 0000000000..52cc9444d6 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.savedrequest; + +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +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.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; + +/** + * An implementation of {@link ServerRequestCache} that saves the + * requested URI in a cookie. + * + * @author Eleftheria Stein + * @since 5.4 + */ +public class CookieServerRequestCache implements ServerRequestCache { + private static final String REDIRECT_URI_COOKIE_NAME = "REDIRECT_URI"; + + private static final Duration COOKIE_MAX_AGE = Duration.ofSeconds(-1); + + private ServerWebExchangeMatcher saveRequestMatcher = createDefaultRequestMatcher(); + + /** + * Sets the matcher to determine if the request should be saved. The default is to match + * on any GET request. + * + * @param saveRequestMatcher the {@link ServerWebExchangeMatcher} that determines if + * the request should be saved + */ + public void setSaveRequestMatcher(ServerWebExchangeMatcher saveRequestMatcher) { + Assert.notNull(saveRequestMatcher, "saveRequestMatcher cannot be null"); + this.saveRequestMatcher = saveRequestMatcher; + } + + @Override + public Mono saveRequest(ServerWebExchange exchange) { + return this.saveRequestMatcher.matches(exchange) + .filter(m -> m.isMatch()) + .map(m -> exchange.getResponse()) + .map(ServerHttpResponse::getCookies) + .doOnNext(cookies -> cookies.add(REDIRECT_URI_COOKIE_NAME, createRedirectUriCookie(exchange.getRequest()))) + .then(); + } + + @Override + public Mono getRedirectUri(ServerWebExchange exchange) { + MultiValueMap cookieMap = exchange.getRequest().getCookies(); + return Mono.justOrEmpty(cookieMap.getFirst(REDIRECT_URI_COOKIE_NAME)) + .map(HttpCookie::getValue) + .map(CookieServerRequestCache::decodeCookie) + .onErrorResume(IllegalArgumentException.class, e -> Mono.empty()) + .map(URI::create); + } + + @Override + public Mono removeMatchingRequest(ServerWebExchange exchange) { + return Mono.just(exchange.getResponse()) + .map(ServerHttpResponse::getCookies) + .doOnNext(cookies -> cookies.add(REDIRECT_URI_COOKIE_NAME, invalidateRedirectUriCookie(exchange.getRequest()))) + .thenReturn(exchange.getRequest()); + } + + private static ResponseCookie createRedirectUriCookie(ServerHttpRequest request) { + String path = request.getPath().pathWithinApplication().value(); + String query = request.getURI().getRawQuery(); + String redirectUri = path + (query != null ? "?" + query : ""); + + return createResponseCookie(request, encodeCookie(redirectUri), COOKIE_MAX_AGE); + } + + private static ResponseCookie invalidateRedirectUriCookie(ServerHttpRequest request) { + return createResponseCookie(request, null, Duration.ZERO); + } + + private static ResponseCookie createResponseCookie(ServerHttpRequest request, String cookieValue, Duration age) { + return ResponseCookie.from(REDIRECT_URI_COOKIE_NAME, cookieValue) + .path(request.getPath().contextPath().value() + "/") + .maxAge(age) + .httpOnly(true) + .secure("https".equalsIgnoreCase(request.getURI().getScheme())) + .sameSite("Lax") + .build(); + } + + private static String encodeCookie(String cookieValue) { + return new String(Base64.getEncoder().encode(cookieValue.getBytes())); + } + + private static String decodeCookie(String encodedCookieValue) { + return new String(Base64.getDecoder().decode(encodedCookieValue.getBytes())); + } + + private static ServerWebExchangeMatcher createDefaultRequestMatcher() { + ServerWebExchangeMatcher get = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); + ServerWebExchangeMatcher notFavicon = new NegatedServerWebExchangeMatcher(ServerWebExchangeMatchers.pathMatchers("/favicon.*")); + MediaTypeServerWebExchangeMatcher html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); + html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); + return new AndServerWebExchangeMatcher(get, notFavicon, html); + } +} diff --git a/web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java b/web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java new file mode 100644 index 0000000000..c8b6be0e6f --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.savedrequest; + +import org.junit.Test; +import org.springframework.http.HttpCookie; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.util.MultiValueMap; + +import java.net.URI; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CookieServerRequestCache} + * + * @author Eleftheria Stein + */ +public class CookieServerRequestCacheTests { + private CookieServerRequestCache cache = new CookieServerRequestCache(); + + @Test + public void saveRequestWhenGetRequestThenRequestUriInCookie() { + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/secured/").accept(MediaType.TEXT_HTML)); + this.cache.saveRequest(exchange).block(); + + MultiValueMap cookies = exchange.getResponse().getCookies(); + assertThat(cookies.size()).isEqualTo(1); + ResponseCookie cookie = cookies.getFirst("REDIRECT_URI"); + assertThat(cookie).isNotNull(); + String encodedRedirectUrl = Base64.getEncoder().encodeToString("/secured/".getBytes()); + assertThat(cookie.toString()).isEqualTo("REDIRECT_URI=" + encodedRedirectUrl + "; Path=/; HttpOnly; SameSite=Lax"); + } + + @Test + public void saveRequestWhenGetRequestWithQueryParamsThenRequestUriInCookie() { + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/secured/").queryParam("key", "value").accept(MediaType.TEXT_HTML)); + this.cache.saveRequest(exchange).block(); + + MultiValueMap cookies = exchange.getResponse().getCookies(); + assertThat(cookies.size()).isEqualTo(1); + ResponseCookie cookie = cookies.getFirst("REDIRECT_URI"); + assertThat(cookie).isNotNull(); + String encodedRedirectUrl = Base64.getEncoder().encodeToString("/secured/?key=value".getBytes()); + assertThat(cookie.toString()).isEqualTo("REDIRECT_URI=" + encodedRedirectUrl + "; Path=/; HttpOnly; SameSite=Lax"); + } + + @Test + public void saveRequestWhenGetRequestFaviconThenNoCookie() { + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/favicon.png").accept(MediaType.TEXT_HTML)); + this.cache.saveRequest(exchange).block(); + + MultiValueMap cookies = exchange.getResponse().getCookies(); + assertThat(cookies).isEmpty(); + } + + @Test + public void saveRequestWhenPostRequestThenNoCookie() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/secured/")); + this.cache.saveRequest(exchange).block(); + + MultiValueMap cookies = exchange.getResponse().getCookies(); + assertThat(cookies).isEmpty(); + } + + @Test + public void saveRequestWhenPostRequestAndCustomMatcherThenRequestUriInCookie() { + this.cache.setSaveRequestMatcher(e -> ServerWebExchangeMatcher.MatchResult.match()); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/secured/")); + this.cache.saveRequest(exchange).block(); + + MultiValueMap cookies = exchange.getResponse().getCookies(); + ResponseCookie cookie = cookies.getFirst("REDIRECT_URI"); + assertThat(cookie).isNotNull(); + + String encodedRedirectUrl = Base64.getEncoder().encodeToString("/secured/".getBytes()); + assertThat(cookie.toString()).isEqualTo("REDIRECT_URI=" + encodedRedirectUrl + "; Path=/; HttpOnly; SameSite=Lax"); + } + + @Test + public void getRedirectUriWhenCookieThenReturnsRedirectUriFromCookie() { + String encodedRedirectUrl = Base64.getEncoder().encodeToString("/secured/".getBytes()); + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/secured/").accept(MediaType.TEXT_HTML).cookie(new HttpCookie("REDIRECT_URI", encodedRedirectUrl))); + + URI redirectUri = this.cache.getRedirectUri(exchange).block(); + + assertThat(redirectUri).isEqualTo(URI.create("/secured/")); + } + + @Test + public void getRedirectUriWhenCookieValueNotEncodedThenRedirectUriIsNull() { + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/secured/").accept(MediaType.TEXT_HTML).cookie(new HttpCookie("REDIRECT_URI", "/secured/"))); + + URI redirectUri = this.cache.getRedirectUri(exchange).block(); + + assertThat(redirectUri).isNull(); + } + + @Test + public void getRedirectUriWhenNoCookieThenRedirectUriIsNull() { + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/secured/").accept(MediaType.TEXT_HTML)); + + URI redirectUri = this.cache.getRedirectUri(exchange).block(); + + assertThat(redirectUri).isNull(); + } + + @Test + public void removeMatchingRequestThenRedirectUriCookieExpired() { + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/secured/").accept(MediaType.TEXT_HTML).cookie(new HttpCookie("REDIRECT_URI", "/secured/"))); + + this.cache.removeMatchingRequest(exchange).block(); + + MultiValueMap cookies = exchange.getResponse().getCookies(); + ResponseCookie cookie = cookies.getFirst("REDIRECT_URI"); + assertThat(cookie).isNotNull(); + assertThat(cookie.toString()).isEqualTo("REDIRECT_URI=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax"); + } +}