Customize CsrfTokenRepository and CsrfAuthenticationStrategy to support cross domain CSRF protection.

This commit is contained in:
Tim Donohue
2020-12-22 14:12:03 -06:00
parent 30451676ab
commit d084358e70
9 changed files with 297 additions and 86 deletions

View File

@@ -152,12 +152,10 @@ public class Application extends SpringBootServletInitializer {
// for our Access-Control-Allow-Origin header // for our Access-Control-Allow-Origin header
.allowCredentials(corsAllowCredentials).allowedOrigins(corsAllowedOrigins) .allowCredentials(corsAllowCredentials).allowedOrigins(corsAllowedOrigins)
// Allow list of request preflight headers allowed to be sent to us from the client // Allow list of request preflight headers allowed to be sent to us from the client
.allowedHeaders("Authorization", "Content-Type", "X-Requested-With", "accept", "Origin", .allowedHeaders("Accept", "Authorization", "Content-Type", "Origin", "X-On-Behalf-Of",
"Access-Control-Request-Method", "Access-Control-Request-Headers", "X-Requested-With", "X-XSRF-TOKEN")
"X-On-Behalf-Of", "X-XSRF-TOKEN") // Allow list of response headers allowed to be sent by us (the server) to the client
// Allow list of response headers allowed to be sent by us (the server) .exposedHeaders("Authorization", "DSPACE-XSRF-TOKEN", "Location", "WWW-Authenticate");
.exposedHeaders("Access-Control-Allow-Origin", "Access-Control-Allow-Credentials",
"Authorization");
} }
} }

View File

@@ -0,0 +1,56 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.exception;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import org.springframework.stereotype.Component;
/**
* This Handler customizes behavior of AccessDeniedException errors thrown by Spring Security/Boot
*/
@Component
public class DSpaceAccessDeniedHandler implements AccessDeniedHandler {
/**
* Override handle() to pass these exceptions over to our DSpaceApiExceptionControllerAdvice handler
* @param request request
* @param response response
* @param ex AccessDeniedException
* @throws IOException
* @throws ServletException
*/
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex)
throws IOException, ServletException {
// Do nothing if response is already committed
if (response.isCommitted()) {
return;
}
// Get access to our general exception handler
DSpaceApiExceptionControllerAdvice handler = new DSpaceApiExceptionControllerAdvice();
// If a CSRF Token was passed in but was invalid pass to csrfTokenException()
if (ex instanceof InvalidCsrfTokenException || ex instanceof MissingCsrfTokenException) {
handler.csrfTokenException(request, response, ex);
return;
}
// Otherwise, our handleAuthorizeException method will deal with generic AccessDeniedExceptions
handler.handleAuthorizeException(request, response, ex);
}
}

View File

@@ -26,6 +26,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -35,13 +37,15 @@ import org.springframework.web.multipart.MultipartException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
/** /**
* This Controller advice will handle all exceptions thrown by the DSpace API module * This Controller advice will handle default exceptions thrown by the DSpace REST API module.
* <P>
* Keep in mind some specialized handlers exist for specific message types, e.g. DSpaceAccessDeniedHandler
* *
* @author Tom Desair (tom dot desair at atmire dot com) * @author Tom Desair (tom dot desair at atmire dot com)
* @author Frederic Van Reet (frederic dot vanreet at atmire dot com) * @author Frederic Van Reet (frederic dot vanreet at atmire dot com)
* @author Andrea Bollini (andrea.bollini at 4science.it) * @author Andrea Bollini (andrea.bollini at 4science.it)
* @author Pasquale Cavallo (pasquale.cavallo at 4science dot it) * @author Pasquale Cavallo (pasquale.cavallo at 4science dot it)
* * @see DSpaceAccessDeniedHandler
*/ */
@ControllerAdvice @ControllerAdvice
public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionHandler { public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionHandler {
@@ -50,6 +54,8 @@ public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionH
@Autowired @Autowired
private RestAuthenticationService restAuthenticationService; private RestAuthenticationService restAuthenticationService;
// NOTE: this method is also called by DSpaceAccessDeniedHandler to handle AccessDeniedExceptions thrown by
// Spring Security
@ExceptionHandler({AuthorizeException.class, RESTAuthorizationException.class, AccessDeniedException.class}) @ExceptionHandler({AuthorizeException.class, RESTAuthorizationException.class, AccessDeniedException.class})
protected void handleAuthorizeException(HttpServletRequest request, HttpServletResponse response, Exception ex) protected void handleAuthorizeException(HttpServletRequest request, HttpServletResponse response, Exception ex)
throws IOException { throws IOException {
@@ -60,6 +66,14 @@ public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionH
} }
} }
// NOTE: this method is also called by DSpaceAccessDeniedHandler to handle CSRF exceptions thrown by Spring Security
@ExceptionHandler({InvalidCsrfTokenException.class, MissingCsrfTokenException.class})
protected void csrfTokenException(HttpServletRequest request, HttpServletResponse response, Exception ex)
throws IOException {
sendErrorResponse(request, response, ex, "Access is denied. Invalid CSRF token.",
HttpServletResponse.SC_FORBIDDEN);
}
@ExceptionHandler({IllegalArgumentException.class, MultipartException.class}) @ExceptionHandler({IllegalArgumentException.class, MultipartException.class})
protected void handleWrongRequestException(HttpServletRequest request, HttpServletResponse response, protected void handleWrongRequestException(HttpServletRequest request, HttpServletResponse response,
Exception ex) throws IOException { Exception ex) throws IOException {
@@ -146,7 +160,15 @@ public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionH
} }
/**
* Send the error to the response. Some errors may also be logged.
* @param request current request
* @param response current response
* @param ex Exception thrown
* @param message message to log or send in response
* @param statusCode status code to send in response
* @throws IOException
*/
private void sendErrorResponse(final HttpServletRequest request, final HttpServletResponse response, private void sendErrorResponse(final HttpServletRequest request, final HttpServletResponse response,
final Exception ex, final String message, final int statusCode) throws IOException { final Exception ex, final String message, final int statusCode) throws IOException {
//Make sure Spring picks up this exception //Make sure Spring picks up this exception

View File

@@ -0,0 +1,83 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.security;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Custom SessionAuthenticationStrategy to be used alongside DSpaceCsrfTokenRepository.
* <P>
* Because DSpace is Stateless, this class only resets the CSRF Token if the client has attempted to use it (either
* successfully or unsuccessfully). This ensures that the Token is not changed on every request (since we are stateless
* every request creates a new Authentication object).
* <P>
* Based on Spring Security's CsrfAuthenticationStrategy:
* https://github.com/spring-projects/spring-security/blob/5.2.x/web/src/main/java/org/springframework/security/web/csrf/CsrfAuthenticationStrategy.java
*/
public class DSpaceCsrfAuthenticationStrategy implements SessionAuthenticationStrategy {
private final CsrfTokenRepository csrfTokenRepository;
/**
* Creates a new instance
* @param csrfTokenRepository the {@link CsrfTokenRepository} to use
*/
public DSpaceCsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
this.csrfTokenRepository = csrfTokenRepository;
}
/**
* This method is triggered anytime a new Authentication occurs. As DSpace uses Stateless authentication,
* this method is triggered on _every request_ after an initial login occurs. This is because the Spring Security
* Authentication object is recreated on every request.
* <P>
* Therefore, for DSpace, we've customized this method to ensure a new CSRF Token is NOT generated each time a new
* Authentication object is created -- doing so causes the CSRF Token to change with every request. Instead, we
* check to see if the client also passed a CSRF token via a header or parameter. If so, this means the client
* has used (or attempted to use) the token & it must then be regenerated.
*/
@Override
public void onAuthentication(Authentication authentication,
HttpServletRequest request, HttpServletResponse response)
throws SessionAuthenticationException {
// Check if token returned in server-side cookie
CsrfToken token = this.csrfTokenRepository.loadToken(request);
// For DSpace, this will only be null if we are forcing CSRF token regeneration (e.g. on initial login)
boolean containsToken = token != null;
if (containsToken) {
// Check for header or parameter in request
boolean containsHeader = StringUtils.hasLength(request.getHeader(token.getHeaderName()));
boolean containsParameter = StringUtils.hasLength(request.getParameter(token.getParameterName()));
// If token exists & we've also been sent either the header or parameter
// then we need to reset our token (as it's been used)
if (containsHeader || containsParameter) {
this.csrfTokenRepository.saveToken(null, request, response);
CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
this.csrfTokenRepository.saveToken(newToken, request, response);
request.setAttribute(CsrfToken.class.getName(), newToken);
request.setAttribute(newToken.getParameterName(), newToken);
}
}
}
}

View File

@@ -22,24 +22,38 @@ import org.springframework.util.StringUtils;
import org.springframework.web.util.WebUtils; import org.springframework.web.util.WebUtils;
/** /**
* This is a Spring Security CookieCsrfTokenRepository which supports cross-site cookies (i.e. SameSite=None). * This is a custom Spring Security CsrfTokenRepository which supports *cross-domain* CSRF protection (allowing the
* PLEASE NOTE: It will NOT support cross-domain CSRF, as Cookies cannot be sent across domains. Therefore, this * client and backend to be on different domains). It's inspired by https://stackoverflow.com/a/33175322
* CsrfTokenRepository is similar to Spring Security's in that it requires the REST API and UI to be on the same domain.
* <P> * <P>
* This code was mostly borrowed from Spring Security's CookieCsrfTokenRepository * This also borrows heavily from Spring Security's CookieCsrfTokenRepository:
* https://github.com/spring-projects/spring-security/blob/5.2.x/web/src/main/java/org/springframework/security/web/csrf/CookieCsrfTokenRepository.java * https://github.com/spring-projects/spring-security/blob/5.2.x/web/src/main/java/org/springframework/security/web/csrf/CookieCsrfTokenRepository.java
* <P> *
* Corresponding tests were also copied to CrossSiteCookieCsrfTokenRepositoryTest. * How it works:
* <P> *
* The only modification were to the saveToken() method below. See that method's JavaDocs. * 1. Backend generates XSRF token & stores in a *server-side* cookie named DSPACE-XSRF-COOKIE. This cookie is
* <P> * only readable to clients on the same domain. But, it is returned (by user's browser) on every subsequent request
* NOTE: This class is TEMPORARY and should be REMOVED as soon as the "SameSite" attribute is supported by * to backend. See "saveToken()" method below.
* Spring Security's CookieCsrfTokenRepository. As soon as the below ticket is resolved & we upgrade Spring Security, * 2. At the same time, backend also sends the generated XSRF token in a header named DSPACE-XSRF-TOKEN to client.
* then this custom class can be removed: * See "saveToken()" method below.
* https://github.com/spring-projects/spring-security/issues/7537 * 3. Client MUST look for DSPACE-XSRF-TOKEN header in a response from backend. If found, the client MUST store/save
* this token for later request(s). For Angular UI, this task is performed by the XsrfInterceptor.
* 4. Whenever the client is making a mutating request (e.g. POST, PUT, DELETE, etc), the XSRF token is REQUIRED to be
* sent back in the X-XSRF-TOKEN header.
* * NOTE: non-mutating requests (e.g. GET, HEAD) do not check for an XSRF token. This is default behavior in
* Spring Security
* 5. On backend, the X-XSRF-TOKEN header is received & compared to the current value of the *server-side* cookie
* named DSPACE-XSRF-COOKIE. If tokens match, the request is accepted. If tokens don't match a 403 is returned.
* This is done automatically by Spring Security.
*
* In summary, the XSRF token is ALWAYS sent to/from the client & backend via *headers*. This is what allows the client
* and backend to be on different domains. The server-side cookie named DSPACE-XSRF-COOKIE is (usually) not accessible
* to the client. It only exists to allow the server-side to remember the currently active XSRF token, so that it can
* validate the token sent (by the client) in the X-XSRF-TOKEN header.
*/ */
public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository { public class DSpaceCsrfTokenRepository implements CsrfTokenRepository {
static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN"; // This cookie name is changed from the default "XSRF-TOKEN" to ensure it is uniquely named and doesn't conflict
// with any other XSRF-TOKEN cookies (e.g. in Angular UI, the XSRF-TOKEN cookie is a *client-side* only cookie)
static final String DEFAULT_CSRF_COOKIE_NAME = "DSPACE-XSRF-COOKIE";
static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf"; static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
@@ -57,7 +71,7 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
private String cookieDomain; private String cookieDomain;
public CrossSiteCookieCsrfTokenRepository() { public DSpaceCsrfTokenRepository() {
} }
@Override @Override
@@ -67,12 +81,14 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
} }
/** /**
* This is the only method modified for DSpace. We changed this method to use ResponseCookie to build the * This method has been modified for DSpace.
* cookie, so that we could hardcode the "SameSite" attribute to a value of "None". This allows for cross site * <P>
* XSRF-TOKEN cookies. * It now uses ResponseCookie to build the cookie, so that the "SameSite" attribute can be applied.
* @param token * <P>
* @param request * It also sends the token (if not empty) in both the cookie and the custom "DSPACE-XSRF-TOKEN" header
* @param response * @param token current token
* @param request current request
* @param response current response
*/ */
@Override @Override
public void saveToken(CsrfToken token, HttpServletRequest request, public void saveToken(CsrfToken token, HttpServletRequest request,
@@ -96,22 +112,33 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
} }
// Custom: Turn the above Cookie into a ResponseCookie so that we can set "SameSite" attribute // Custom: Turn the above Cookie into a ResponseCookie so that we can set "SameSite" attribute
// NOTE: ONLY set "SameSite=None" if cookie is also secure. Most modern browsers will block it otherwise. // If client is on a different domain than the backend, then Cookie MUST use "SameSite=None" and "Secure".
// This means that DSpace MUST USE HTTPS if the UI is on a different domain then backend. // Most modern browsers will block it otherwise.
String sameSite = ""; // TODO: Make SameSite configurable? "Lax" cookies are more secure, but require client & backend on same domain.
if (cookie.getSecure()) { String sameSite = "None";
sameSite = "None"; if (!cookie.getSecure()) {
sameSite = "Lax";
} }
ResponseCookie responseCookie = ResponseCookie.from(cookie.getName(), cookie.getValue()) ResponseCookie responseCookie = ResponseCookie.from(cookie.getName(), cookie.getValue())
.path(cookie.getPath()).maxAge(cookie.getMaxAge()) .path(cookie.getPath()).maxAge(cookie.getMaxAge())
.domain(cookie.getDomain()).httpOnly(cookie.isHttpOnly()) .domain(cookie.getDomain()).httpOnly(cookie.isHttpOnly())
.secure(cookie.getSecure()).sameSite(sameSite).build(); .secure(cookie.getSecure()).sameSite(sameSite).build();
// Write the ResponseCookie to the Set-Cookie header // Write the ResponseCookie to the Set-Cookie header
// This cookie is only used by the backend & not needed by client
response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString()); response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
// Send custom header to client with token (only if token not empty)
// We send our token via a custom header because client can be on a different domain.
// Cookies cannot be reliably sent cross-domain.
if (StringUtils.hasLength(tokenValue)) {
response.setHeader("DSPACE-XSRF-TOKEN", tokenValue);
}
} }
@Override @Override
public CsrfToken loadToken(HttpServletRequest request) { public CsrfToken loadToken(HttpServletRequest request) {
// First, verify the (server-side) cookie was sent back
Cookie cookie = WebUtils.getCookie(request, this.cookieName); Cookie cookie = WebUtils.getCookie(request, this.cookieName);
if (cookie == null) { if (cookie == null) {
return null; return null;
@@ -120,6 +147,17 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
if (!StringUtils.hasLength(token)) { if (!StringUtils.hasLength(token)) {
return null; return null;
} }
// Second, verify either the header or param has been sent. This is a customization for DSpace.
// Because the server-side cookie is ALWAYS sent back, we need to verify the client has also sent the token in
// some other way. This ensures that we only *change* the Token when it has been used or attempted to be used.
//if (!StringUtils.hasLength(request.getHeader(this.headerName)) &&
// !StringUtils.hasLength(request.getParameter(this.parameterName))) {
// return null;
// }
// If we got here, we know a token exists in the cookie and *either* the header or the parameter.
// So, this just sends the token info back so that it can be validated by Spring Security.
return new DefaultCsrfToken(this.headerName, this.parameterName, token); return new DefaultCsrfToken(this.headerName, this.parameterName, token);
} }
@@ -179,8 +217,8 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
* @return an instance of CookieCsrfTokenRepository with * @return an instance of CookieCsrfTokenRepository with
* {@link #setCookieHttpOnly(boolean)} set to false * {@link #setCookieHttpOnly(boolean)} set to false
*/ */
public static CrossSiteCookieCsrfTokenRepository withHttpOnlyFalse() { public static DSpaceCsrfTokenRepository withHttpOnlyFalse() {
CrossSiteCookieCsrfTokenRepository result = new CrossSiteCookieCsrfTokenRepository(); DSpaceCsrfTokenRepository result = new DSpaceCsrfTokenRepository();
result.setCookieHttpOnly(false); result.setCookieHttpOnly(false);
return result; return result;
} }

View File

@@ -7,6 +7,7 @@
*/ */
package org.dspace.app.rest.security; package org.dspace.app.rest.security;
import org.dspace.app.rest.exception.DSpaceAccessDeniedHandler;
import org.dspace.authenticate.service.AuthenticationService; import org.dspace.authenticate.service.AuthenticationService;
import org.dspace.services.RequestService; import org.dspace.services.RequestService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -24,6 +25,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.csrf.CsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -58,6 +60,9 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired @Autowired
private AuthenticationService authenticationService; private AuthenticationService authenticationService;
@Autowired
private DSpaceAccessDeniedHandler accessDeniedHandler;
@Override @Override
public void configure(WebSecurity webSecurity) throws Exception { public void configure(WebSecurity webSecurity) throws Exception {
// Define URL patterns which Spring Security will ignore entirely. // Define URL patterns which Spring Security will ignore entirely.
@@ -91,12 +96,18 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
.servletApi().and() .servletApi().and()
// Enable CORS for Spring Security (see CORS settings in Application and ApplicationConfig) // Enable CORS for Spring Security (see CORS settings in Application and ApplicationConfig)
.cors().and() .cors().and()
// Enable CSRF protection with custom CookieCsrfTokenRepository (see below) designed for Angular apps // Enable CSRF protection with custom csrfTokenRepository and custom sessionAuthenticationStrategy
// (both are defined below as methods).
// While we primarily use JWT in headers, CSRF protection is needed because we also support JWT via Cookies // While we primarily use JWT in headers, CSRF protection is needed because we also support JWT via Cookies
.csrf().csrfTokenRepository(this.getCsrfTokenRepository()).and() .csrf()
.csrfTokenRepository(this.getCsrfTokenRepository())
.sessionAuthenticationStrategy(this.sessionAuthenticationStrategy())
.and()
.exceptionHandling()
// Return 401 on authorization failures with a correct WWWW-Authenticate header // Return 401 on authorization failures with a correct WWWW-Authenticate header
.exceptionHandling().authenticationEntryPoint( .authenticationEntryPoint(new DSpace401AuthenticationEntryPoint(restAuthenticationService))
new DSpace401AuthenticationEntryPoint(restAuthenticationService)) // Custom handler for AccessDeniedExceptions, including CSRF exceptions
.accessDeniedHandler(accessDeniedHandler)
.and() .and()
// Logout configuration // Logout configuration
@@ -137,27 +148,30 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
} }
/** /**
* Override the defaults of CookieCsrfTokenRepository to always set the Cookie Path to "/" * Returns a custom DSpaceCsrfTokenRepository based on Spring Security's CookieCsrfTokenRepository, which is
* designed for Angular Apps.
* <P> * <P>
* We use the CookieCsrfTokenRepository designed for Angular apps * The DSpaceCsrfTokenRepository stores the token in server-side cookie (for later verification), but sends it to
* See https://docs.spring.io/spring-security/site/docs/current/reference/html5/#servlet-csrf * the client as a DSPACE-XSRF-TOKEN header. The client is expected to return the token in either a header named
* X-XSRF-TOKEN *or* a URL parameter named "_csrf", at which point it is validated against the server-side cookie.
* <P> * <P>
* This CookieCsrfTokenRepository will write a cookie named XSRF-TOKEN and read it from * This behavior is based on the defaults for Angular apps: https://angular.io/guide/http#security-xsrf-protection.
* a header named X-XSRF-TOKEN *or* a URL parameter named "_csrf". Angular apps will respond to * However, instead of sending an XSRF-TOKEN Cookie (as is usual for Angular apps), we send the DSPACE-XSRF-TOKEN
* XSRF-TOKEN automatically, see: https://angular.io/guide/http#security-xsrf-protection * header...as this ensures the Angular app can receive the token even if it is on a different domain.
* <P> *
* However, currently Angular *requires* the CSR cookie path to always be "/" or it will ignore it. * @return CsrfTokenRepository as described above
* See: https://stackoverflow.com/a/50511663
* @return CookieCsrfTokenRepository with cookie path="/"
*/ */
private CsrfTokenRepository getCsrfTokenRepository() { private CsrfTokenRepository getCsrfTokenRepository() {
// We are using a *custom* CrossSiteCookieCsrfTokenRepository in which sets // NOTE: Created cookie is set to HttpOnly=false to allow Hal Browser (or other local JS clients) to access it.
// "SameSite=None" to allow this XSRF-TOKEN cookie to be used in cross site requests. return DSpaceCsrfTokenRepository.withHttpOnlyFalse();
// This custom class should be REMOVED when this Spring Security ticket is resolved: }
// https://github.com/spring-projects/spring-security/issues/7537
CrossSiteCookieCsrfTokenRepository tokenRepository = CrossSiteCookieCsrfTokenRepository.withHttpOnlyFalse(); /**
tokenRepository.setCookiePath("/"); * Returns a custom DSpaceCsrfAuthenticationStrategy, which ensures that (after authenticating) the CSRF token
return tokenRepository; * is only refreshed when it is used (or attempted to be used) by the client.
*/
private SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new DSpaceCsrfAuthenticationStrategy(getCsrfTokenRepository());
} }
} }

View File

@@ -21,10 +21,10 @@ HAL.Http.Client = function(opts) {
}; };
/** /**
* Get CSRF Token by parsing it out of the XSRF-TOKEN cookie sent by our DSpace server webapp * Get CSRF Token by parsing it out of the DSPACE-XSRF-COOKIE (server-side) cookie set by our DSpace server webapp
**/ **/
function getCSRFToken() { function getCSRFToken() {
var cookie = document.cookie.match('(^|;)\\s*' + 'XSRF-TOKEN' + '\\s*=\\s*([^;]+)'); var cookie = document.cookie.match('(^|;)\\s*' + 'DSPACE-XSRF-COOKIE' + '\\s*=\\s*([^;]+)');
if(cookie != undefined) { if(cookie != undefined) {
return cookie.pop(); return cookie.pop();
} else { } else {

View File

@@ -139,10 +139,10 @@
} }
/** /**
* Get CSRF Token by parsing it out of the XSRF-TOKEN cookie sent by our DSpace server webapp * Get CSRF Token by parsing it out of the DSPACE-XSRF-COOKIE server-side cookie set by our DSpace server webapp
**/ **/
function getCSRFToken() { function getCSRFToken() {
var cookie = document.cookie.match('(^|;)\\s*' + 'XSRF-TOKEN' + '\\s*=\\s*([^;]+)'); var cookie = document.cookie.match('(^|;)\\s*' + 'DSPACE-XSRF-COOKIE' + '\\s*=\\s*([^;]+)');
if(cookie != undefined) { if(cookie != undefined) {
return cookie.pop(); return cookie.pop();
} else { } else {

View File

@@ -26,18 +26,18 @@ import org.springframework.security.web.csrf.CsrfToken;
* https://github.com/spring-projects/spring-security/blob/5.2.x/web/src/test/java/org/springframework/security/web/csrf/CookieCsrfTokenRepositoryTests.java * https://github.com/spring-projects/spring-security/blob/5.2.x/web/src/test/java/org/springframework/security/web/csrf/CookieCsrfTokenRepositoryTests.java
* *
* The only modifications are: * The only modifications are:
* - Updating these tests to use our custom CrossSiteCookieCsrfTokenRepository * - Updating these tests to use our custom DSpaceCsrfTokenRepository
* - Updating the saveTokenSecure() test, where we check for our custom SameSite attribute. * - Updating the saveTokenSecure() test, where we check for our custom SameSite attribute.
*/ */
@RunWith(MockitoJUnitRunner.class) @RunWith(MockitoJUnitRunner.class)
public class CrossSiteCookieCsrfTokenRepositoryTest { public class DSpaceCsrfTokenRepositoryTest {
CrossSiteCookieCsrfTokenRepository repository; DSpaceCsrfTokenRepository repository;
MockHttpServletResponse response; MockHttpServletResponse response;
MockHttpServletRequest request; MockHttpServletRequest request;
@Before @Before
public void setup() { public void setup() {
this.repository = new CrossSiteCookieCsrfTokenRepository(); this.repository = new DSpaceCsrfTokenRepository();
this.request = new MockHttpServletRequest(); this.request = new MockHttpServletRequest();
this.response = new MockHttpServletResponse(); this.response = new MockHttpServletResponse();
this.request.setContextPath("/context"); this.request.setContextPath("/context");
@@ -49,9 +49,9 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
assertThat(generateToken).isNotNull(); assertThat(generateToken).isNotNull();
assertThat(generateToken.getHeaderName()) assertThat(generateToken.getHeaderName())
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME); .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME);
assertThat(generateToken.getParameterName()) assertThat(generateToken.getParameterName())
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME); .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME);
assertThat(generateToken.getToken()).isNotEmpty(); assertThat(generateToken.getToken()).isNotEmpty();
} }
@@ -76,11 +76,11 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getMaxAge()).isEqualTo(-1); assertThat(tokenCookie.getMaxAge()).isEqualTo(-1);
assertThat(tokenCookie.getName()) assertThat(tokenCookie.getName())
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath());
assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure()); assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure());
assertThat(tokenCookie.getValue()).isEqualTo(token.getToken()); assertThat(tokenCookie.getValue()).isEqualTo(token.getToken());
@@ -94,7 +94,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getSecure()).isTrue(); assertThat(tokenCookie.getSecure()).isTrue();
// DSpace Custom assert to verify SameSite attribute is set // DSpace Custom assert to verify SameSite attribute is set
@@ -111,11 +111,11 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(null, this.request, this.response); this.repository.saveToken(null, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getMaxAge()).isZero(); assertThat(tokenCookie.getMaxAge()).isZero();
assertThat(tokenCookie.getName()) assertThat(tokenCookie.getName())
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath());
assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure()); assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure());
assertThat(tokenCookie.getValue()).isEmpty(); assertThat(tokenCookie.getValue()).isEmpty();
@@ -128,7 +128,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.isHttpOnly()).isTrue(); assertThat(tokenCookie.isHttpOnly()).isTrue();
} }
@@ -140,19 +140,19 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.isHttpOnly()).isFalse(); assertThat(tokenCookie.isHttpOnly()).isFalse();
} }
@Test @Test
public void saveTokenWithHttpOnlyFalse() { public void saveTokenWithHttpOnlyFalse() {
this.repository = CrossSiteCookieCsrfTokenRepository.withHttpOnlyFalse(); this.repository = DSpaceCsrfTokenRepository.withHttpOnlyFalse();
CsrfToken token = this.repository.generateToken(this.request); CsrfToken token = this.repository.generateToken(this.request);
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.isHttpOnly()).isFalse(); assertThat(tokenCookie.isHttpOnly()).isFalse();
} }
@@ -165,7 +165,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getPath()).isEqualTo(this.repository.getCookiePath()); assertThat(tokenCookie.getPath()).isEqualTo(this.repository.getCookiePath());
} }
@@ -178,7 +178,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath());
} }
@@ -191,7 +191,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath());
} }
@@ -205,7 +205,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
this.repository.saveToken(token, this.request, this.response); this.repository.saveToken(token, this.request, this.response);
Cookie tokenCookie = this.response Cookie tokenCookie = this.response
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
assertThat(tokenCookie.getDomain()).isEqualTo(domainName); assertThat(tokenCookie.getDomain()).isEqualTo(domainName);
} }
@@ -225,7 +225,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
@Test @Test
public void loadTokenCookieValueEmptyString() { public void loadTokenCookieValueEmptyString() {
this.request.setCookies( this.request.setCookies(
new Cookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, "")); new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, ""));
assertThat(this.repository.loadToken(this.request)).isNull(); assertThat(this.repository.loadToken(this.request)).isNull();
} }
@@ -235,7 +235,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
CsrfToken generateToken = this.repository.generateToken(this.request); CsrfToken generateToken = this.repository.generateToken(this.request);
this.request this.request
.setCookies(new Cookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, .setCookies(new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME,
generateToken.getToken())); generateToken.getToken()));
CsrfToken loadToken = this.repository.loadToken(this.request); CsrfToken loadToken = this.repository.loadToken(this.request);