diff --git a/web/src/main/java/org/springframework/security/web/server/csrf/CookieServerCsrfTokenRepository.java b/web/src/main/java/org/springframework/security/web/server/csrf/CookieServerCsrfTokenRepository.java new file mode 100644 index 00000000000..25dd53a56ec --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/csrf/CookieServerCsrfTokenRepository.java @@ -0,0 +1,230 @@ +/* + * 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.csrf; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.http.HttpCookie; +import org.springframework.http.ResponseCookie; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * A {@link ServerCsrfTokenRepository} that persists the CSRF token in a cookie named "XSRF-TOKEN" and + * reads from the header "X-XSRF-TOKEN" following the conventions of AngularJS. When using with + * AngularJS be sure to use {@link #withHttpOnlyFalse()} . + * + * @author Eric Deandrea + * @since 5.1 + */ +public final class CookieServerCsrfTokenRepository implements ServerCsrfTokenRepository { + static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN"; + static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf"; + static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN"; + + private String parameterName = DEFAULT_CSRF_PARAMETER_NAME; + private String headerName = DEFAULT_CSRF_HEADER_NAME; + private String cookiePath; + private String cookieDomain; + private String cookieName = DEFAULT_CSRF_COOKIE_NAME; + private boolean cookieHttpOnly = true; + + /** + * Factory method to conveniently create an instance that has + * {@link #setCookieHttpOnly(boolean)} set to false. + * + * @return an instance of CookieCsrfTokenRepository with + * {@link #setCookieHttpOnly(boolean)} set to false + */ + public static CookieServerCsrfTokenRepository withHttpOnlyFalse() { + return new CookieServerCsrfTokenRepository().withCookieHttpOnly(false); + } + + @Override + public Mono generateToken(ServerWebExchange exchange) { + return Mono.fromCallable(this::createCsrfToken); + } + + @Override + public Mono saveToken(ServerWebExchange exchange, CsrfToken token) { + Optional tokenValue = Optional.ofNullable(token).map(CsrfToken::getToken); + + ResponseCookie cookie = ResponseCookie.from(this.cookieName, tokenValue.orElse("")) + .domain(this.cookieDomain) + .httpOnly(this.cookieHttpOnly) + .maxAge(tokenValue.map(val -> -1).orElse(0)) + .path(Optional.ofNullable(this.cookiePath).orElseGet(() -> getRequestContext(exchange.getRequest()))) + .secure(Optional.ofNullable(exchange.getRequest().getSslInfo()).map(sslInfo -> true).orElse(false)) + .build(); + + exchange.getResponse().addCookie(cookie); + + return Mono.empty(); + } + + @Override + public Mono loadToken(ServerWebExchange exchange) { + Optional token = Optional.ofNullable(exchange.getRequest()) + .map(ServerHttpRequest::getCookies) + .map(cookiesMap -> cookiesMap.getFirst(this.cookieName)) + .map(HttpCookie::getValue) + .map(this::createCsrfToken); + + return Mono.justOrEmpty(token); + } + + /** + * Sets the HttpOnly attribute on the cookie containing the CSRF token + * @param cookieHttpOnly True to mark the cookie as http only. False otherwise. + */ + public void setCookieHttpOnly(boolean cookieHttpOnly) { + this.cookieHttpOnly = cookieHttpOnly; + } + + /** + * Sets the HttpOnly attribute on the cookie containing the CSRF token + * @param cookieHttpOnly True to mark the cookie as http only. False otherwise. + * @return This instance + */ + public CookieServerCsrfTokenRepository withCookieHttpOnly(boolean cookieHttpOnly) { + setCookieHttpOnly(cookieHttpOnly); + return this; + } + + /** + * Sets the cookie name + * @param cookieName The cookie name + */ + public void setCookieName(String cookieName) { + Assert.hasLength(cookieName, "cookieName can't be null"); + this.cookieName = cookieName; + } + + /** + * Sets the cookie name + * @param cookieName The cookie name + * @return This instance + */ + public CookieServerCsrfTokenRepository withCookieName(String cookieName) { + setCookieName(cookieName); + return this; + } + + /** + * Sets the parameter name + * @param parameterName The parameter name + */ + public void setParameterName(String parameterName) { + Assert.hasLength(parameterName, "parameterName can't be null"); + this.parameterName = parameterName; + } + + /** + * Sets the parameter name + * @param parameterName The parameter name + * @return This instance + */ + public CookieServerCsrfTokenRepository withParameterName(String parameterName) { + setParameterName(parameterName); + return this; + } + + /** + * Sets the header name + * @param headerName The header name + * @return This instance + */ + public void setHeaderName(String headerName) { + Assert.hasLength(headerName, "headerName can't be null"); + this.headerName = headerName; + } + + /** + * Sets the header name + * @param headerName The header name + * @return This instance + */ + public CookieServerCsrfTokenRepository withHeaderName(String headerName) { + setHeaderName(headerName); + return this; + } + + /** + * Sets the cookie path + * @param cookiePath The cookie path + * @return This instance + */ + public void setCookiePath(String cookiePath) { + this.cookiePath = cookiePath; + } + + /** + * Sets the cookie path + * @param cookiePath The cookie path + * @return This instance + */ + public CookieServerCsrfTokenRepository withCookiePath(String cookiePath) { + setCookiePath(cookiePath); + return this; + } + + /** + * Sets the cookie domain + * @param cookieDomain The cookie domain + * @return This instance + */ + public void setCookieDomain(String cookieDomain) { + this.cookieDomain = cookieDomain; + } + + /** + * Sets the cookie domain + * @param cookieDomain The cookie domain + * @return This instance + */ + public CookieServerCsrfTokenRepository withCookieDomain(String cookieDomain) { + setCookieDomain(cookieDomain); + return this; + } + + private CsrfToken createCsrfToken() { + return createCsrfToken(createNewToken()); + } + + private CsrfToken createCsrfToken(String tokenValue) { + return new DefaultCsrfToken(this.headerName, this.parameterName, tokenValue); + } + + private String createNewToken() { + return UUID.randomUUID().toString(); + } + + private String getRequestContext(ServerHttpRequest request) { + return Optional.ofNullable(request) + .map(ServerHttpRequest::getPath) + .map(RequestPath::contextPath) + .map(PathContainer::value) + .filter(contextPath -> contextPath.length() > 0) + .orElse("/"); + } +} diff --git a/web/src/test/java/org/springframework/security/web/server/csrf/CookieServerCsrfTokenRepositoryTests.java b/web/src/test/java/org/springframework/security/web/server/csrf/CookieServerCsrfTokenRepositoryTests.java new file mode 100644 index 00000000000..60009df2dff --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/csrf/CookieServerCsrfTokenRepositoryTests.java @@ -0,0 +1,254 @@ +/* + * 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.csrf; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.function.Supplier; + +import org.junit.Test; + +import org.springframework.http.ResponseCookie; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * @author Eric Deandrea + * @since 5.1 + */ +public class CookieServerCsrfTokenRepositoryTests { + @Test + public void generateTokenDefault() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new); + Mono csrfTokenMono = csrfTokenRepository.generateToken(exchange); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(csrfTokenMono.block()) + .isNotNull() + .extracting("headerName", "parameterName") + .containsExactly(CookieServerCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME, CookieServerCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME); + assertThat(csrfTokenMono.block().getToken()).isNotBlank(); + } + + @Test + public void generateTokenChangeHeaderName() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, + "someHeader", + CookieServerCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME); + Mono csrfTokenMono = csrfTokenRepository.generateToken(exchange); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(csrfTokenMono.block()) + .isNotNull() + .extracting("headerName", "parameterName") + .containsExactly("someHeader", CookieServerCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME); + assertThat(csrfTokenMono.block().getToken()).isNotBlank(); + } + + @Test + public void generateTokenChangeParameterName() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME, + "someParam"); + Mono csrfTokenMono = csrfTokenRepository.generateToken(exchange); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(csrfTokenMono.block()) + .isNotNull() + .extracting("headerName", "parameterName") + .containsExactly(CookieServerCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME, "someParam"); + assertThat(csrfTokenMono.block().getToken()).isNotBlank(); + } + + @Test + public void generateTokenChangeHeaderAndParameterName() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, + "someHeader", + "someParam"); + Mono csrfTokenMono = csrfTokenRepository.generateToken(exchange); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(csrfTokenMono.block()) + .isNotNull() + .extracting("headerName", "parameterName") + .containsExactly("someHeader", "someParam"); + assertThat(csrfTokenMono.block().getToken()).isNotBlank(); + } + + @Test + public void saveTokenDefault() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new); + + Mono csrfTokenMono = csrfTokenRepository.saveToken(exchange, createToken("someTokenValue")); + ResponseCookie cookie = exchange + .getResponse() + .getCookies() + .getFirst(CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(cookie) + .isNotNull() + .extracting("maxAge", "domain", "path", "secure", "httpOnly", "name", "value") + .containsExactly(Duration.ofSeconds(-1), null, "/", false, true, "XSRF-TOKEN", "someTokenValue"); + } + + @Test + public void saveTokenMaxAge() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new); + + Mono csrfTokenMono = csrfTokenRepository.saveToken(exchange, null); + ResponseCookie cookie = exchange + .getResponse() + .getCookies() + .getFirst(CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(cookie) + .isNotNull() + .extracting("maxAge", "domain", "path", "secure", "httpOnly", "name", "value") + .containsExactly(Duration.ofSeconds(0), null, "/", false, true, "XSRF-TOKEN", ""); + } + + @Test + public void saveTokenHttpOnly() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::withHttpOnlyFalse); + + Mono csrfTokenMono = csrfTokenRepository.saveToken(exchange, createToken("someTokenValue")); + ResponseCookie cookie = exchange + .getResponse() + .getCookies() + .getFirst(CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(cookie) + .isNotNull() + .extracting("maxAge", "domain", "path", "secure", "httpOnly", "name", "value") + .containsExactly(Duration.ofSeconds(-1), null, "/", false, false, "XSRF-TOKEN", "someTokenValue"); + } + + @Test + public void saveTokenOverriddenViaCsrfProps() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new, + ".spring.io", "csrfCookie", "/some/path", + "headerName", "paramName"); + + Mono csrfTokenMono = + csrfTokenRepository.saveToken(exchange, createToken("headerName", "paramName", "someTokenValue")); + ResponseCookie cookie = exchange.getResponse().getCookies().getFirst("csrfCookie"); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(cookie) + .isNotNull() + .extracting("maxAge", "domain", "path", "secure", "httpOnly", "name", "value") + .containsExactly(Duration.ofSeconds(-1), ".spring.io", "/some/path", false, true, "csrfCookie", "someTokenValue"); + } + + @Test + public void loadTokenThatExists() { + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/someUri") + .cookie(ResponseCookie.from(CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, "someTokenValue").build())); + + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new); + Mono csrfTokenMono = csrfTokenRepository.loadToken(exchange); + + assertThat(csrfTokenMono).isNotNull(); + assertThat(csrfTokenMono.block()) + .isNotNull() + .extracting("headerName", "parameterName", "token") + .containsExactly( + CookieServerCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME, + "someTokenValue"); + } + + @Test + public void loadTokenThatDoesntExists() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/someUri")); + CookieServerCsrfTokenRepository csrfTokenRepository = + CookieServerCsrfTokenRepositoryFactory.createRepository(CookieServerCsrfTokenRepository::new); + + Mono csrfTokenMono = csrfTokenRepository.loadToken(exchange); + assertThat(csrfTokenMono).isNotNull(); + assertThat(csrfTokenMono.block()).isNull(); + } + + private static CsrfToken createToken(String tokenValue) { + return createToken(CookieServerCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME, tokenValue); + } + + private static CsrfToken createToken(String headerName, String parameterName, String tokenValue) { + return new DefaultCsrfToken(headerName, parameterName, tokenValue); + } + + static final class CookieServerCsrfTokenRepositoryFactory { + private CookieServerCsrfTokenRepositoryFactory() { + super(); + } + + static CookieServerCsrfTokenRepository createRepository(Supplier cookieServerCsrfTokenRepositorySupplier) { + return createRepository(cookieServerCsrfTokenRepositorySupplier, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME, + CookieServerCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME); + } + + static CookieServerCsrfTokenRepository createRepository( + Supplier cookieServerCsrfTokenRepositorySupplier, + String cookieName, String headerName, String parameterName) { + + return createRepository(cookieServerCsrfTokenRepositorySupplier, + null, cookieName, null, headerName, parameterName); + } + + static CookieServerCsrfTokenRepository createRepository( + Supplier cookieServerCsrfTokenRepositorySupplier, + String cookieDomain, String cookieName, String cookiePath, String headerName, String parameterName) { + + return cookieServerCsrfTokenRepositorySupplier.get() + .withCookieDomain(cookieDomain) + .withCookieName(cookieName) + .withCookiePath(cookiePath) + .withHeaderName(headerName) + .withParameterName(parameterName); + } + } +} \ No newline at end of file