From 82d527ed42dafd6feaac1c77b2be13513e3778da Mon Sep 17 00:00:00 2001 From: Rafiullah Hamedy Date: Fri, 22 Feb 2019 00:38:50 -0500 Subject: [PATCH] Add Support for Clear Site Data on Logout Added an implementation of HeaderWriter for Clear-Site-Data HTTP response header as welll as an implementation of LogoutHanlder that accepts an implementation of HeaderWriter to write headers. - Added ClearSiteDataHeaderWriter and HeaderWriterLogoutHandler that implements HeaderWriter and LogoutHandler respectively - Added unit tests for both implementations's behaviours - Integration tests for HeaderWriterLogoutHandler that uses ClearSiteDataHeaderWriter - Updated the documentation to include link to HeaderWriterLogoutHandler Fixes gh-4187 --- .../LogoutConfigurerClearSiteDataTests.java | 99 +++++++++++++++++ .../servlet/preface/java-configuration.adoc | 1 + .../logout/HeaderWriterLogoutHandler.java | 50 +++++++++ .../writers/ClearSiteDataHeaderWriter.java | 101 +++++++++++++++++ .../HeaderWriterLogoutHandlerTests.java | 104 ++++++++++++++++++ .../ClearSiteDataHeaderWriterTests.java | 95 ++++++++++++++++ 6 files changed, 450 insertions(+) create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerClearSiteDataTests.java create mode 100644 web/src/main/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandler.java create mode 100644 web/src/main/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriter.java create mode 100644 web/src/test/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandlerTests.java create mode 100644 web/src/test/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriterTests.java diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerClearSiteDataTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerClearSiteDataTests.java new file mode 100644 index 00000000000..068822c6ae2 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerClearSiteDataTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-2019 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.annotation.web.configurers; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.test.SpringTestRule; +import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler; +import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; + +/** + * + * Tests for {@link HeaderWriterLogoutHandler} that passing {@link ClearSiteDataHeaderWriter} + * implementation. + * + * @author Rafiullah Hamedy + * + */ +@RunWith(SpringRunner.class) +@SecurityTestExecutionListeners +public class LogoutConfigurerClearSiteDataTests { + + private static final String CLEAR_SITE_DATA_HEADER = "Clear-Site-Data"; + + private static final String[] SOURCE = {"cache", "cookies", "storage", "executionContexts"}; + + private static final String HEADER_VALUE = "\"cache\", \"cookies\", \"storage\", \"executionContexts\""; + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Autowired + MockMvc mvc; + + @Test + @WithMockUser + public void logoutWhenRequestTypeGetThenHeaderNotPresentt() throws Exception { + this.spring.register(HttpLogoutConfig.class).autowire(); + + this.mvc.perform(get("/logout").secure(true).with(csrf())) + .andExpect(header().doesNotExist(CLEAR_SITE_DATA_HEADER)); + } + + @Test + @WithMockUser + public void logoutWhenRequestTypePostAndNotSecureThenHeaderNotPresent() throws Exception { + this.spring.register(HttpLogoutConfig.class).autowire(); + + this.mvc.perform(post("/logout").with(csrf())) + .andExpect(header().doesNotExist(CLEAR_SITE_DATA_HEADER)); + } + + @Test + @WithMockUser + public void logoutWhenRequestTypePostAndSecureThenHeaderIsPresent() throws Exception { + this.spring.register(HttpLogoutConfig.class).autowire(); + + this.mvc.perform(post("/logout").secure(true).with(csrf())) + .andExpect(header().stringValues(CLEAR_SITE_DATA_HEADER, HEADER_VALUE)); + } + + @EnableWebSecurity + static class HttpLogoutConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .logout() + .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(SOURCE))); + } + } +} \ No newline at end of file diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/preface/java-configuration.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/preface/java-configuration.adoc index dcbb7431d73..9b300d95706 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/preface/java-configuration.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/preface/java-configuration.adoc @@ -343,6 +343,7 @@ Various implementations are provided: - {security-api-url}org/springframework/security/web/authentication/logout/CookieClearingLogoutHandler.html[CookieClearingLogoutHandler] - {security-api-url}org/springframework/security/web/csrf/CsrfLogoutHandler.html[CsrfLogoutHandler] - {security-api-url}org/springframework/security/web/authentication/logout/SecurityContextLogoutHandler.html[SecurityContextLogoutHandler] +- {security-api-url}org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandler.html[HeaderWriterLogoutHandler] Please see <> for details. diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandler.java b/web/src/main/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandler.java new file mode 100644 index 00000000000..1583aa460ec --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandler.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2019 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.authentication.logout; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.header.HeaderWriter; +import org.springframework.util.Assert; + +/** + * + * @author Rafiullah Hamedy + * @since 5.2 + */ +public final class HeaderWriterLogoutHandler implements LogoutHandler { + private final HeaderWriter headerWriter; + + /** + * Constructs a new instance using the passed {@link HeaderWriter} implementation + * + * @param headerWriter + * @throws {@link IllegalArgumentException} if headerWriter is null. + */ + public HeaderWriterLogoutHandler(HeaderWriter headerWriter) { + Assert.notNull(headerWriter, "headerWriter cannot be null."); + this.headerWriter = headerWriter; + } + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + this.headerWriter.writeHeaders(request, response); + } +} diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriter.java new file mode 100644 index 00000000000..b86ffbed95a --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriter.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2019 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.header.writers; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.security.web.header.HeaderWriter; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * Provides support for Clear + * Site Data. + * + *

+ * Developers may instruct a user agent to clear various types of relevant data by delivering + * a Clear-Site-Data HTTP response header in response to a request. + *

+ * + *

+ * Due to Incomplete Clearing + * section the header is only applied if the request is secure. + *

+ * + * @author Rafiullah Hamedy + * @since 5.2 + */ +public final class ClearSiteDataHeaderWriter implements HeaderWriter { + + private static final String CLEAR_SITE_DATA_HEADER = "Clear-Site-Data"; + + private final Log logger = LogFactory.getLog(getClass()); + + private final RequestMatcher requestMatcher; + + private String headerValue; + + /** + *

+ * Creates a new instance of {@link ClearSiteDataHeaderWriter} with given sources. + * The constructor also initializes requestMatcher with a new instance of + * SecureRequestMatcher to ensure that header is only applied if and when + * the request is secure as per the Incomplete Clearing section. + *

+ * + * @param sources (i.e. "cache", "cookies", "storage", "executionContexts" or "*") + * @throws {@link IllegalArgumentException} if sources is null or empty. + */ + public ClearSiteDataHeaderWriter(String ...sources) { + Assert.notEmpty(sources, "Sources cannot be empty or null."); + this.requestMatcher = new SecureRequestMatcher(); + this.headerValue = Stream.of(sources).map(this::quote).collect(Collectors.joining(", ")); + } + + @Override + public void writeHeaders(HttpServletRequest request, HttpServletResponse response) { + if (this.requestMatcher.matches(request)) { + if (!response.containsHeader(CLEAR_SITE_DATA_HEADER)) { + response.setHeader(CLEAR_SITE_DATA_HEADER, this.headerValue); + } + } else if (logger.isDebugEnabled()) { + logger.debug("Not injecting Clear-Site-Data header since it did not match the " + + "requestMatcher " + this.requestMatcher); + } + } + + private static final class SecureRequestMatcher implements RequestMatcher { + public boolean matches(HttpServletRequest request) { + return request.isSecure(); + } + } + + private String quote(String source) { + return "\"" + source + "\""; + } + + @Override + public String toString() { + return getClass().getName() + " [headerValue=" + this.headerValue + "]"; + } +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandlerTests.java b/web/src/test/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandlerTests.java new file mode 100644 index 00000000000..e01d72d3278 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/logout/HeaderWriterLogoutHandlerTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2002-2019 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.authentication.logout; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; + +/** + * + * @author Rafiullah Hamedy + * + * @see {@link HeaderWriterLogoutHandler} + */ +public class HeaderWriterLogoutHandlerTests { + private static final String HEADER_NAME = "Clear-Site-Data"; + + private MockHttpServletResponse response; + private MockHttpServletRequest request; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Before + public void setup() { + this.response = new MockHttpServletResponse(); + this.request = new MockHttpServletRequest(); + } + + @Test + public void createInstanceWhenHeaderWriterIsNullThenThrowsException() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("headerWriter cannot be null."); + + new HeaderWriterLogoutHandler(null); + } + + @Test + public void createInstanceWhenSourceIsNullThenThrowsException() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Sources cannot be empty or null."); + + new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter()); + } + + @Test + public void logoutWhenRequestIsNotSecureThenHeaderIsNotPresent() { + HeaderWriterLogoutHandler handler = new HeaderWriterLogoutHandler( + new ClearSiteDataHeaderWriter("cache")); + + handler.logout(request, response, mock(Authentication.class)); + + assertThat(header().doesNotExist(HEADER_NAME)); + } + + @Test + public void logoutWhenRequestIsSecureThenHeaderIsPresentMatchesWildCardSource() { + HeaderWriterLogoutHandler handler = new HeaderWriterLogoutHandler( + new ClearSiteDataHeaderWriter("*")); + + this.request.setSecure(true); + + handler.logout(request, response, mock(Authentication.class)); + + assertThat(header().stringValues(HEADER_NAME, "\"*\"")); + } + + @Test + public void logoutWhenRequestIsSecureThenHeaderValueMatchesSource() { + HeaderWriterLogoutHandler handler = new HeaderWriterLogoutHandler( + new ClearSiteDataHeaderWriter("cache", "cookies", "storage", + "executionContexts")); + + this.request.setSecure(true); + + handler.logout(request, response, mock(Authentication.class)); + + assertThat(header().stringValues(HEADER_NAME, "\"cache\", \"cookies\", \"storage\", " + + "\"executionContexts\"")); + } +} diff --git a/web/src/test/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriterTests.java new file mode 100644 index 00000000000..fb7ece8804f --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/header/writers/ClearSiteDataHeaderWriterTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2019 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.header.writers; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; + +/** + * + * @author Rafiullah Hamedy + * + * @see {@link ClearSiteDataHeaderWriter} + */ +public class ClearSiteDataHeaderWriterTests { + private static final String HEADER_NAME = "Clear-Site-Data"; + + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Before + public void setup() { + request = new MockHttpServletRequest(); + request.setSecure(true); + response = new MockHttpServletResponse(); + } + + @Test + public void createInstanceWhenMissingSourceThenThrowsException() { + this.thrown.expect(Exception.class); + this.thrown.expectMessage("Sources cannot be empty or null."); + + new ClearSiteDataHeaderWriter(); + } + + @Test + public void createInstanceWhenEmptySourceThenThrowsException() { + this.thrown.expect(Exception.class); + this.thrown.expectMessage("Sources cannot be empty or null."); + + new ClearSiteDataHeaderWriter(new String[] {}); + } + + @Test + public void writeHeaderWhenRequestNotSecureThenHeaderIsNotPresent() { + this.request.setSecure(false); + + ClearSiteDataHeaderWriter headerWriter = new ClearSiteDataHeaderWriter("cache"); + headerWriter.writeHeaders(request, response); + + assertThat(header().doesNotExist(HEADER_NAME)); + } + + @Test + public void writeHeaderWhenRequestIsSecureThenHeaderValueMatchesPassedSource() { + ClearSiteDataHeaderWriter headerWriter = new ClearSiteDataHeaderWriter("storage"); + headerWriter.writeHeaders(request, response); + + assertThat(header().stringValues(HEADER_NAME, "\"storage\"")); + } + + @Test + public void writeHeaderWhenRequestIsSecureThenHeaderValueMatchesPassedSources() { + ClearSiteDataHeaderWriter headerWriter = + new ClearSiteDataHeaderWriter("cache", "cookies", "storage", "executionContexts"); + + headerWriter.writeHeaders(request, response); + + assertThat(header().stringValues(HEADER_NAME, "\"cache\", \"cookies\", \"storage\"," + + " \"executionContexts\"")); + } +}