mirror of
https://github.com/DSpace/DSpace.git
synced 2025-10-07 01:54:22 +00:00
Customize CsrfTokenRepository and CsrfAuthenticationStrategy to support cross domain CSRF protection.
This commit is contained in:
@@ -152,12 +152,10 @@ public class Application extends SpringBootServletInitializer {
|
||||
// for our Access-Control-Allow-Origin header
|
||||
.allowCredentials(corsAllowCredentials).allowedOrigins(corsAllowedOrigins)
|
||||
// Allow list of request preflight headers allowed to be sent to us from the client
|
||||
.allowedHeaders("Authorization", "Content-Type", "X-Requested-With", "accept", "Origin",
|
||||
"Access-Control-Request-Method", "Access-Control-Request-Headers",
|
||||
"X-On-Behalf-Of", "X-XSRF-TOKEN")
|
||||
// Allow list of response headers allowed to be sent by us (the server)
|
||||
.exposedHeaders("Access-Control-Allow-Origin", "Access-Control-Allow-Credentials",
|
||||
"Authorization");
|
||||
.allowedHeaders("Accept", "Authorization", "Content-Type", "Origin", "X-On-Behalf-Of",
|
||||
"X-Requested-With", "X-XSRF-TOKEN")
|
||||
// Allow list of response headers allowed to be sent by us (the server) to the client
|
||||
.exposedHeaders("Authorization", "DSPACE-XSRF-TOKEN", "Location", "WWW-Authenticate");
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -26,6 +26,8 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.annotation.ControllerAdvice;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 Frederic Van Reet (frederic dot vanreet at atmire dot com)
|
||||
* @author Andrea Bollini (andrea.bollini at 4science.it)
|
||||
* @author Pasquale Cavallo (pasquale.cavallo at 4science dot it)
|
||||
*
|
||||
* @see DSpaceAccessDeniedHandler
|
||||
*/
|
||||
@ControllerAdvice
|
||||
public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionHandler {
|
||||
@@ -50,6 +54,8 @@ public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionH
|
||||
@Autowired
|
||||
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})
|
||||
protected void handleAuthorizeException(HttpServletRequest request, HttpServletResponse response, Exception ex)
|
||||
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})
|
||||
protected void handleWrongRequestException(HttpServletRequest request, HttpServletResponse response,
|
||||
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,
|
||||
final Exception ex, final String message, final int statusCode) throws IOException {
|
||||
//Make sure Spring picks up this exception
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -22,24 +22,38 @@ import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.WebUtils;
|
||||
|
||||
/**
|
||||
* This is a Spring Security CookieCsrfTokenRepository which supports cross-site cookies (i.e. SameSite=None).
|
||||
* PLEASE NOTE: It will NOT support cross-domain CSRF, as Cookies cannot be sent across domains. Therefore, this
|
||||
* CsrfTokenRepository is similar to Spring Security's in that it requires the REST API and UI to be on the same domain.
|
||||
* This is a custom Spring Security CsrfTokenRepository which supports *cross-domain* CSRF protection (allowing the
|
||||
* client and backend to be on different domains). It's inspired by https://stackoverflow.com/a/33175322
|
||||
* <P>
|
||||
* This code was mostly borrowed 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
|
||||
* <P>
|
||||
* Corresponding tests were also copied to CrossSiteCookieCsrfTokenRepositoryTest.
|
||||
* <P>
|
||||
* The only modification were to the saveToken() method below. See that method's JavaDocs.
|
||||
* <P>
|
||||
* NOTE: This class is TEMPORARY and should be REMOVED as soon as the "SameSite" attribute is supported by
|
||||
* Spring Security's CookieCsrfTokenRepository. As soon as the below ticket is resolved & we upgrade Spring Security,
|
||||
* then this custom class can be removed:
|
||||
* https://github.com/spring-projects/spring-security/issues/7537
|
||||
* 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
|
||||
*
|
||||
* How it works:
|
||||
*
|
||||
* 1. Backend generates XSRF token & stores in a *server-side* cookie named DSPACE-XSRF-COOKIE. This cookie is
|
||||
* only readable to clients on the same domain. But, it is returned (by user's browser) on every subsequent request
|
||||
* to backend. See "saveToken()" method below.
|
||||
* 2. At the same time, backend also sends the generated XSRF token in a header named DSPACE-XSRF-TOKEN to client.
|
||||
* See "saveToken()" method below.
|
||||
* 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 {
|
||||
static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
|
||||
public class DSpaceCsrfTokenRepository implements CsrfTokenRepository {
|
||||
// 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";
|
||||
|
||||
@@ -57,7 +71,7 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
|
||||
|
||||
private String cookieDomain;
|
||||
|
||||
public CrossSiteCookieCsrfTokenRepository() {
|
||||
public DSpaceCsrfTokenRepository() {
|
||||
}
|
||||
|
||||
@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
|
||||
* cookie, so that we could hardcode the "SameSite" attribute to a value of "None". This allows for cross site
|
||||
* XSRF-TOKEN cookies.
|
||||
* @param token
|
||||
* @param request
|
||||
* @param response
|
||||
* This method has been modified for DSpace.
|
||||
* <P>
|
||||
* It now uses ResponseCookie to build the cookie, so that the "SameSite" attribute can be applied.
|
||||
* <P>
|
||||
* It also sends the token (if not empty) in both the cookie and the custom "DSPACE-XSRF-TOKEN" header
|
||||
* @param token current token
|
||||
* @param request current request
|
||||
* @param response current response
|
||||
*/
|
||||
@Override
|
||||
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
|
||||
// NOTE: ONLY set "SameSite=None" if cookie is also secure. Most modern browsers will block it otherwise.
|
||||
// This means that DSpace MUST USE HTTPS if the UI is on a different domain then backend.
|
||||
String sameSite = "";
|
||||
if (cookie.getSecure()) {
|
||||
sameSite = "None";
|
||||
// If client is on a different domain than the backend, then Cookie MUST use "SameSite=None" and "Secure".
|
||||
// Most modern browsers will block it otherwise.
|
||||
// TODO: Make SameSite configurable? "Lax" cookies are more secure, but require client & backend on same domain.
|
||||
String sameSite = "None";
|
||||
if (!cookie.getSecure()) {
|
||||
sameSite = "Lax";
|
||||
}
|
||||
ResponseCookie responseCookie = ResponseCookie.from(cookie.getName(), cookie.getValue())
|
||||
.path(cookie.getPath()).maxAge(cookie.getMaxAge())
|
||||
.domain(cookie.getDomain()).httpOnly(cookie.isHttpOnly())
|
||||
.secure(cookie.getSecure()).sameSite(sameSite).build();
|
||||
|
||||
// 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());
|
||||
|
||||
// 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
|
||||
public CsrfToken loadToken(HttpServletRequest request) {
|
||||
// First, verify the (server-side) cookie was sent back
|
||||
Cookie cookie = WebUtils.getCookie(request, this.cookieName);
|
||||
if (cookie == null) {
|
||||
return null;
|
||||
@@ -120,6 +147,17 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
|
||||
if (!StringUtils.hasLength(token)) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -179,8 +217,8 @@ public class CrossSiteCookieCsrfTokenRepository implements CsrfTokenRepository {
|
||||
* @return an instance of CookieCsrfTokenRepository with
|
||||
* {@link #setCookieHttpOnly(boolean)} set to false
|
||||
*/
|
||||
public static CrossSiteCookieCsrfTokenRepository withHttpOnlyFalse() {
|
||||
CrossSiteCookieCsrfTokenRepository result = new CrossSiteCookieCsrfTokenRepository();
|
||||
public static DSpaceCsrfTokenRepository withHttpOnlyFalse() {
|
||||
DSpaceCsrfTokenRepository result = new DSpaceCsrfTokenRepository();
|
||||
result.setCookieHttpOnly(false);
|
||||
return result;
|
||||
}
|
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
package org.dspace.app.rest.security;
|
||||
|
||||
import org.dspace.app.rest.exception.DSpaceAccessDeniedHandler;
|
||||
import org.dspace.authenticate.service.AuthenticationService;
|
||||
import org.dspace.services.RequestService;
|
||||
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.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
|
||||
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.util.matcher.AntPathRequestMatcher;
|
||||
|
||||
@@ -58,6 +60,9 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
|
||||
@Autowired
|
||||
private AuthenticationService authenticationService;
|
||||
|
||||
@Autowired
|
||||
private DSpaceAccessDeniedHandler accessDeniedHandler;
|
||||
|
||||
@Override
|
||||
public void configure(WebSecurity webSecurity) throws Exception {
|
||||
// Define URL patterns which Spring Security will ignore entirely.
|
||||
@@ -91,12 +96,18 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
|
||||
.servletApi().and()
|
||||
// Enable CORS for Spring Security (see CORS settings in Application and ApplicationConfig)
|
||||
.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
|
||||
.csrf().csrfTokenRepository(this.getCsrfTokenRepository()).and()
|
||||
// Return 401 on authorization failures with a correct WWWW-Authenticate header
|
||||
.exceptionHandling().authenticationEntryPoint(
|
||||
new DSpace401AuthenticationEntryPoint(restAuthenticationService))
|
||||
.csrf()
|
||||
.csrfTokenRepository(this.getCsrfTokenRepository())
|
||||
.sessionAuthenticationStrategy(this.sessionAuthenticationStrategy())
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
// Return 401 on authorization failures with a correct WWWW-Authenticate header
|
||||
.authenticationEntryPoint(new DSpace401AuthenticationEntryPoint(restAuthenticationService))
|
||||
// Custom handler for AccessDeniedExceptions, including CSRF exceptions
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
.and()
|
||||
|
||||
// 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>
|
||||
* We use the CookieCsrfTokenRepository designed for Angular apps
|
||||
* See https://docs.spring.io/spring-security/site/docs/current/reference/html5/#servlet-csrf
|
||||
* The DSpaceCsrfTokenRepository stores the token in server-side cookie (for later verification), but sends it to
|
||||
* 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>
|
||||
* This CookieCsrfTokenRepository will write a cookie named XSRF-TOKEN and read it from
|
||||
* a header named X-XSRF-TOKEN *or* a URL parameter named "_csrf". Angular apps will respond to
|
||||
* XSRF-TOKEN automatically, see: https://angular.io/guide/http#security-xsrf-protection
|
||||
* <P>
|
||||
* However, currently Angular *requires* the CSR cookie path to always be "/" or it will ignore it.
|
||||
* See: https://stackoverflow.com/a/50511663
|
||||
* @return CookieCsrfTokenRepository with cookie path="/"
|
||||
* This behavior is based on the defaults for Angular apps: https://angular.io/guide/http#security-xsrf-protection.
|
||||
* However, instead of sending an XSRF-TOKEN Cookie (as is usual for Angular apps), we send the DSPACE-XSRF-TOKEN
|
||||
* header...as this ensures the Angular app can receive the token even if it is on a different domain.
|
||||
*
|
||||
* @return CsrfTokenRepository as described above
|
||||
*/
|
||||
private CsrfTokenRepository getCsrfTokenRepository() {
|
||||
// We are using a *custom* CrossSiteCookieCsrfTokenRepository in which sets
|
||||
// "SameSite=None" to allow this XSRF-TOKEN cookie to be used in cross site requests.
|
||||
// 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("/");
|
||||
return tokenRepository;
|
||||
// NOTE: Created cookie is set to HttpOnly=false to allow Hal Browser (or other local JS clients) to access it.
|
||||
return DSpaceCsrfTokenRepository.withHttpOnlyFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a custom DSpaceCsrfAuthenticationStrategy, which ensures that (after authenticating) the CSRF token
|
||||
* is only refreshed when it is used (or attempted to be used) by the client.
|
||||
*/
|
||||
private SessionAuthenticationStrategy sessionAuthenticationStrategy() {
|
||||
return new DSpaceCsrfAuthenticationStrategy(getCsrfTokenRepository());
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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() {
|
||||
var cookie = document.cookie.match('(^|;)\\s*' + 'XSRF-TOKEN' + '\\s*=\\s*([^;]+)');
|
||||
var cookie = document.cookie.match('(^|;)\\s*' + 'DSPACE-XSRF-COOKIE' + '\\s*=\\s*([^;]+)');
|
||||
if(cookie != undefined) {
|
||||
return cookie.pop();
|
||||
} else {
|
||||
|
@@ -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() {
|
||||
var cookie = document.cookie.match('(^|;)\\s*' + 'XSRF-TOKEN' + '\\s*=\\s*([^;]+)');
|
||||
var cookie = document.cookie.match('(^|;)\\s*' + 'DSPACE-XSRF-COOKIE' + '\\s*=\\s*([^;]+)');
|
||||
if(cookie != undefined) {
|
||||
return cookie.pop();
|
||||
} else {
|
||||
|
@@ -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
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
CrossSiteCookieCsrfTokenRepository repository;
|
||||
public class DSpaceCsrfTokenRepositoryTest {
|
||||
DSpaceCsrfTokenRepository repository;
|
||||
MockHttpServletResponse response;
|
||||
MockHttpServletRequest request;
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
this.repository = new CrossSiteCookieCsrfTokenRepository();
|
||||
this.repository = new DSpaceCsrfTokenRepository();
|
||||
this.request = new MockHttpServletRequest();
|
||||
this.response = new MockHttpServletResponse();
|
||||
this.request.setContextPath("/context");
|
||||
@@ -49,9 +49,9 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
|
||||
assertThat(generateToken).isNotNull();
|
||||
assertThat(generateToken.getHeaderName())
|
||||
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME);
|
||||
.isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME);
|
||||
assertThat(generateToken.getParameterName())
|
||||
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME);
|
||||
.isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME);
|
||||
assertThat(generateToken.getToken()).isNotEmpty();
|
||||
}
|
||||
|
||||
@@ -76,11 +76,11 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, 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.getName())
|
||||
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath());
|
||||
assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure());
|
||||
assertThat(tokenCookie.getValue()).isEqualTo(token.getToken());
|
||||
@@ -94,7 +94,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, this.response);
|
||||
|
||||
Cookie tokenCookie = this.response
|
||||
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
|
||||
assertThat(tokenCookie.getSecure()).isTrue();
|
||||
// DSpace Custom assert to verify SameSite attribute is set
|
||||
@@ -111,11 +111,11 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(null, this.request, this.response);
|
||||
|
||||
Cookie tokenCookie = this.response
|
||||
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
|
||||
assertThat(tokenCookie.getMaxAge()).isZero();
|
||||
assertThat(tokenCookie.getName())
|
||||
.isEqualTo(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath());
|
||||
assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure());
|
||||
assertThat(tokenCookie.getValue()).isEmpty();
|
||||
@@ -128,7 +128,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, this.response);
|
||||
|
||||
Cookie tokenCookie = this.response
|
||||
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
|
||||
assertThat(tokenCookie.isHttpOnly()).isTrue();
|
||||
}
|
||||
@@ -140,19 +140,19 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, this.response);
|
||||
|
||||
Cookie tokenCookie = this.response
|
||||
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
|
||||
assertThat(tokenCookie.isHttpOnly()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveTokenWithHttpOnlyFalse() {
|
||||
this.repository = CrossSiteCookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
this.repository = DSpaceCsrfTokenRepository.withHttpOnlyFalse();
|
||||
CsrfToken token = this.repository.generateToken(this.request);
|
||||
this.repository.saveToken(token, this.request, this.response);
|
||||
|
||||
Cookie tokenCookie = this.response
|
||||
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
|
||||
assertThat(tokenCookie.isHttpOnly()).isFalse();
|
||||
}
|
||||
@@ -165,7 +165,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, 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());
|
||||
}
|
||||
@@ -178,7 +178,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, 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());
|
||||
}
|
||||
@@ -191,7 +191,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, 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());
|
||||
}
|
||||
@@ -205,7 +205,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
this.repository.saveToken(token, this.request, this.response);
|
||||
|
||||
Cookie tokenCookie = this.response
|
||||
.getCookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME);
|
||||
|
||||
assertThat(tokenCookie.getDomain()).isEqualTo(domainName);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
@Test
|
||||
public void loadTokenCookieValueEmptyString() {
|
||||
this.request.setCookies(
|
||||
new Cookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, ""));
|
||||
new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, ""));
|
||||
|
||||
assertThat(this.repository.loadToken(this.request)).isNull();
|
||||
}
|
||||
@@ -235,7 +235,7 @@ public class CrossSiteCookieCsrfTokenRepositoryTest {
|
||||
CsrfToken generateToken = this.repository.generateToken(this.request);
|
||||
|
||||
this.request
|
||||
.setCookies(new Cookie(CrossSiteCookieCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME,
|
||||
.setCookies(new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME,
|
||||
generateToken.getToken()));
|
||||
|
||||
CsrfToken loadToken = this.repository.loadToken(this.request);
|
Reference in New Issue
Block a user