Skip to content

Commit

Permalink
Add Support for Clear Site Data on Logout
Browse files Browse the repository at this point in the history
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 spring-projectsgh-4187
  • Loading branch information
rhamedy committed Feb 23, 2019
1 parent 0c2a7e0 commit 0724b0d
Show file tree
Hide file tree
Showing 6 changed files with 450 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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)));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<remember-me-impls>> for details.

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 <a href="https://w3c.github.io/webappsec-clear-site-data/">Clear
* Site Data</a>.
*
* <p>
* 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.
* <p>
*
* <p>
* Due to <a href="https://w3c.github.io/webappsec-clear-site-data/#incomplete">Incomplete Clearing</a>
* section the header is only applied if the request is secure.
* </p>
*
* @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;

/**
* <p>
* Creates a new instance of {@link ClearSiteDataHeaderWriter} with given sources.
* The constructor also initializes <b>requestMatcher</b> with a new instance of
* <b>SecureRequestMatcher</b> to ensure that header is only applied if and when
* the request is secure as per the <b>Incomplete Clearing</b> section.
* </p>
*
* @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 + "]";
}
}
Original file line number Diff line number Diff line change
@@ -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\""));
}
}
Loading

0 comments on commit 0724b0d

Please sign in to comment.