diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfAuthenticationStrategy.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfAuthenticationStrategy.java index 4949c9701a..20b0c14773 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfAuthenticationStrategy.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfAuthenticationStrategy.java @@ -9,11 +9,17 @@ package org.dspace.app.rest.security; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; 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.CsrfAuthenticationStrategy; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.DeferredCsrfToken; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -24,29 +30,41 @@ import org.springframework.util.StringUtils; * 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). *

- * 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 + * This is essentially a customization of Spring Security's CsrfAuthenticationStrategy: + * https://github.com/spring-projects/spring-security/blob/6.2.x/web/src/main/java/org/springframework/security/web/csrf/CsrfAuthenticationStrategy.java */ public class DSpaceCsrfAuthenticationStrategy implements SessionAuthenticationStrategy { - private final CsrfTokenRepository csrfTokenRepository; + private final Log logger = LogFactory.getLog(getClass()); + + private final CsrfTokenRepository tokenRepository; + + private CsrfTokenRequestHandler requestHandler = new XorCsrfTokenRequestAttributeHandler(); /** * Creates a new instance - * @param csrfTokenRepository the {@link CsrfTokenRepository} to use + * @param tokenRepository the {@link CsrfTokenRepository} to use */ - public DSpaceCsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) { - Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null"); - this.csrfTokenRepository = csrfTokenRepository; + public DSpaceCsrfAuthenticationStrategy(CsrfTokenRepository tokenRepository) { + Assert.notNull(tokenRepository, "tokenRepository cannot be null"); + this.tokenRepository = tokenRepository; + } + + /** + * Method is copied from {@link CsrfAuthenticationStrategy#setRequestHandler(CsrfTokenRequestHandler)} + */ + public void setRequestHandler(CsrfTokenRequestHandler requestHandler) { + Assert.notNull(requestHandler, "requestHandler cannot be null"); + this.requestHandler = requestHandler; } /** * 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. + * 'Authentication' object is recreated on every request. *

* 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 + * Authentication object is created -- as 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 querystring parameter (i.e. "_csrf"). If so, this means * the client has sent the token in a less secure manner & it must then be regenerated. *

@@ -57,8 +75,10 @@ public class DSpaceCsrfAuthenticationStrategy implements SessionAuthenticationSt HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException { + // Check if token returned in server-side cookie - CsrfToken token = this.csrfTokenRepository.loadToken(request); + CsrfToken token = this.tokenRepository.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; @@ -69,18 +89,28 @@ public class DSpaceCsrfAuthenticationStrategy implements SessionAuthenticationSt // If token exists was sent in a parameter, then we need to reset our token // (as sending token in a param is insecure) if (containsParameter) { - // Note: We first set the token to null & then set a new one. This results in 2 cookies sent, - // the first being empty and the second having the new token. - // This behavior is borrowed from Spring Security's CsrfAuthenticationStrategy, see - // https://github.com/spring-projects/spring-security/blob/5.4.x/web/src/main/java/org/springframework/security/web/csrf/CsrfAuthenticationStrategy.java - 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); + resetCSRFToken(request, response); } } } + /** + * A custom utility method to force Spring Security to reset the CSRF token. This is used by DSpace to reset + * the token whenever the CSRF token is passed insecurely (as a request param, see onAuthentication() above) + * or on logout (see JWTTokenRestAuthenticationServiceImpl) + * @param request current HTTP request + * @param response current HTTP response + * @see org.dspace.app.rest.security.jwt.JWTTokenRestAuthenticationServiceImpl + */ + public void resetCSRFToken(HttpServletRequest request, HttpServletResponse response) { + // Note: We first set the token to null & then set a new one. This results in 2 cookies sent, + // the first being empty and the second having the new token. + // This behavior is borrowed from Spring Security's CsrfAuthenticationStrategy, see + // https://github.com/spring-projects/spring-security/blob/6.2.x/web/src/main/java/org/springframework/security/web/csrf/CsrfAuthenticationStrategy.java + this.tokenRepository.saveToken(null, request, response); + DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response); + this.requestHandler.handle(request, response, deferredCsrfToken::get); + this.logger.debug("Replaced CSRF Token"); + } + } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepository.java index 2ea3a3a0ad..e2ffbb9b81 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepository.java @@ -8,12 +8,13 @@ package org.dspace.app.rest.security; import java.util.UUID; +import java.util.function.Consumer; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.ws.rs.core.HttpHeaders; import org.springframework.http.ResponseCookie; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfTokenRepository; import org.springframework.security.web.csrf.DefaultCsrfToken; @@ -25,9 +26,10 @@ import org.springframework.web.util.WebUtils; * 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 *

- * 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 - * + * This is essentially a customization of Spring Security's CookieCsrfTokenRepository: + * https://github.com/spring-projects/spring-security/blob/6.2.x/web/src/main/java/org/springframework/security/web/csrf/CookieCsrfTokenRepository.java + * However, as that class is "final" we aannot override it directly. + *

* How it works: * * 1. Backend generates XSRF token & stores in a *server-side* cookie named DSPACE-XSRF-COOKIE. By default, this cookie @@ -59,6 +61,9 @@ public class DSpaceCsrfTokenRepository implements CsrfTokenRepository { static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN"; + private static final String CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME = CookieCsrfTokenRepository.class.getName() + .concat(".REMOVED"); + private String parameterName = DEFAULT_CSRF_PARAMETER_NAME; private String headerName = DEFAULT_CSRF_HEADER_NAME; @@ -71,73 +76,93 @@ public class DSpaceCsrfTokenRepository implements CsrfTokenRepository { private String cookieDomain; + private Boolean secure; + + private int cookieMaxAge = -1; + + private Consumer cookieCustomizer = (builder) -> { + }; + public DSpaceCsrfTokenRepository() { } - @Override - public CsrfToken generateToken(HttpServletRequest request) { - return new DefaultCsrfToken(this.headerName, this.parameterName, - createNewToken()); + /** + * Method is copied from {@link CookieCsrfTokenRepository#setCookieCustomizer(Consumer)} + */ + public void setCookieCustomizer(Consumer cookieCustomizer) { + Assert.notNull(cookieCustomizer, "cookieCustomizer must not be null"); + this.cookieCustomizer = cookieCustomizer; } /** - * This method has been modified for DSpace. + * Method is copied from {@link CookieCsrfTokenRepository#generateToken(HttpServletRequest)} + */ + @Override + public CsrfToken generateToken(HttpServletRequest request) { + return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken()); + } + + /** + * This method has been modified for DSpace. It borrows MOST of the logic from + * {@link CookieCsrfTokenRepository#saveToken(CsrfToken, HttpServletRequest, HttpServletResponse)} *

- * It now uses ResponseCookie to build the cookie, so that the "SameSite" attribute can be applied. + * It applies a "SameSite" attribute to every cookie by default. *

- * It also sends the token (if not empty) in both the cookie and the custom "DSPACE-XSRF-TOKEN" header + * It also sends the token (if not empty) back in BOTH the cookie and the custom "DSPACE-XSRF-TOKEN" header. + * By default, Spring Security will only send the token back in the cookie. * @param token current token * @param request current request * @param response current response */ @Override - public void saveToken(CsrfToken token, HttpServletRequest request, - HttpServletResponse response) { - String tokenValue = token == null ? "" : token.getToken(); - Cookie cookie = new Cookie(this.cookieName, tokenValue); - cookie.setSecure(request.isSecure()); - if (this.cookiePath != null && !this.cookiePath.isEmpty()) { - cookie.setPath(this.cookiePath); - } else { - cookie.setPath(this.getRequestContext(request)); - } - if (token == null) { - cookie.setMaxAge(0); - } else { - cookie.setMaxAge(-1); - } - cookie.setHttpOnly(cookieHttpOnly); - if (this.cookieDomain != null && !this.cookieDomain.isEmpty()) { - cookie.setDomain(this.cookieDomain); - } + public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { + String tokenValue = (token != null) ? token.getToken() : ""; - // Custom: Turn the above Cookie into a ResponseCookie so that we can set "SameSite" attribute - // 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(); + ResponseCookie.ResponseCookieBuilder cookieBuilder = + ResponseCookie.from(this.cookieName, tokenValue) + .secure((this.secure != null) ? this.secure : request.isSecure()) + .path(StringUtils.hasLength(this.cookiePath) ? + this.cookiePath : this.getRequestContext(request)) + .maxAge((token != null) ? this.cookieMaxAge : 0) + .httpOnly(this.cookieHttpOnly) + .domain(this.cookieDomain) + // Custom for DSpace: 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. + .sameSite(request.isSecure() ? "None" : "Lax");; - // 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()); + this.cookieCustomizer.accept(cookieBuilder); - // 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. + // Custom for DSpace: also send custom header to client with token. + // We send our token via a custom header because client may be on a different domain. // Cookies cannot be reliably sent cross-domain. if (StringUtils.hasLength(tokenValue)) { response.setHeader("DSPACE-XSRF-TOKEN", tokenValue); } + + Cookie cookie = mapToCookie(cookieBuilder.build()); + response.addCookie(cookie); + + // Set request attribute to signal that response has blank cookie value, + // which allows loadToken to return null when token has been removed + if (!StringUtils.hasLength(tokenValue)) { + request.setAttribute(CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME, Boolean.TRUE); + } else { + request.removeAttribute(CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME); + } } + /** + * Method is copied from {@link CookieCsrfTokenRepository#loadToken(HttpServletRequest)} + */ @Override public CsrfToken loadToken(HttpServletRequest request) { + // Return null when token has been removed during the current request + // which allows loadDeferredToken to re-generate the token + if (Boolean.TRUE.equals(request.getAttribute(CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME))) { + return null; + } Cookie cookie = WebUtils.getCookie(request, this.cookieName); if (cookie == null) { return null; @@ -146,104 +171,124 @@ public class DSpaceCsrfTokenRepository implements CsrfTokenRepository { if (!StringUtils.hasLength(token)) { return null; } - return new DefaultCsrfToken(this.headerName, this.parameterName, token); } - /** - * Sets the name of the HTTP request parameter that should be used to provide a token. - * - * @param parameterName the name of the HTTP request parameter that should be used to - * provide a token + * Method is copied from {@link CookieCsrfTokenRepository#setParameterName(String)} */ public void setParameterName(String parameterName) { - Assert.notNull(parameterName, "parameterName is not null"); + Assert.notNull(parameterName, "parameterName cannot be null"); this.parameterName = parameterName; } /** - * Sets the name of the HTTP header that should be used to provide the token. - * - * @param headerName the name of the HTTP header that should be used to provide the - * token + * Method is copied from {@link CookieCsrfTokenRepository#setHeaderName(String)} */ public void setHeaderName(String headerName) { - Assert.notNull(headerName, "headerName is not null"); + Assert.notNull(headerName, "headerName cannot be null"); this.headerName = headerName; } /** - * Sets the name of the cookie that the expected CSRF token is saved to and read from. - * - * @param cookieName the name of the cookie that the expected CSRF token is saved to - * and read from + * Method is copied from {@link CookieCsrfTokenRepository#setCookieName(String)} */ public void setCookieName(String cookieName) { - Assert.notNull(cookieName, "cookieName is not null"); + Assert.notNull(cookieName, "cookieName cannot be null"); this.cookieName = cookieName; } /** - * Sets the HttpOnly attribute on the cookie containing the CSRF token. - * Defaults to true. - * - * @param cookieHttpOnly true sets the HttpOnly attribute, false does not set it + * Method is copied from {@link CookieCsrfTokenRepository#setCookieHttpOnly(boolean)} + * @deprecated Use {@link #setCookieCustomizer(Consumer)} instead. */ + @Deprecated public void setCookieHttpOnly(boolean cookieHttpOnly) { this.cookieHttpOnly = cookieHttpOnly; } + /** + * Method is copied from {@link CookieCsrfTokenRepository} + */ private String getRequestContext(HttpServletRequest request) { String contextPath = request.getContextPath(); - return contextPath.length() > 0 ? contextPath : "/"; + return (contextPath.length() > 0) ? contextPath : "/"; } /** - * Factory method to conveniently create an instance that has - * {@link #setCookieHttpOnly(boolean)} set to false. - * - * @return an instance of CookieCsrfTokenRepository with - * {@link #setCookieHttpOnly(boolean)} set to false + * Method is copied from {@link CookieCsrfTokenRepository} + * (and only modified to return the DSpaceCsrfTokenRepository instead) */ public static DSpaceCsrfTokenRepository withHttpOnlyFalse() { DSpaceCsrfTokenRepository result = new DSpaceCsrfTokenRepository(); - result.setCookieHttpOnly(false); + result.cookieHttpOnly = false; return result; } + /** + * Method is copied from {@link CookieCsrfTokenRepository} + */ private String createNewToken() { return UUID.randomUUID().toString(); } /** - * Set the path that the Cookie will be created with. This will override the default functionality which uses the - * request context as the path. - * - * @param path the path to use + * Method is copied from {@link CookieCsrfTokenRepository} + */ + private Cookie mapToCookie(ResponseCookie responseCookie) { + Cookie cookie = new Cookie(responseCookie.getName(), responseCookie.getValue()); + cookie.setSecure(responseCookie.isSecure()); + cookie.setPath(responseCookie.getPath()); + cookie.setMaxAge((int) responseCookie.getMaxAge().getSeconds()); + cookie.setHttpOnly(responseCookie.isHttpOnly()); + if (StringUtils.hasLength(responseCookie.getDomain())) { + cookie.setDomain(responseCookie.getDomain()); + } + if (StringUtils.hasText(responseCookie.getSameSite())) { + cookie.setAttribute("SameSite", responseCookie.getSameSite()); + } + return cookie; + } + + /** + * Method is copied from {@link CookieCsrfTokenRepository#setCookiePath(String)} */ public void setCookiePath(String path) { this.cookiePath = path; } /** - * Get the path that the CSRF cookie will be set to. - * - * @return the path to be used. + * Method is copied from {@link CookieCsrfTokenRepository#getCookiePath()} */ public String getCookiePath() { return this.cookiePath; } /** - * Sets the domain of the cookie that the expected CSRF token is saved to and read from. - * - * @since 5.2 - * @param cookieDomain the domain of the cookie that the expected CSRF token is saved to - * and read from + * Method is copied from {@link CookieCsrfTokenRepository#setCookieDomain(String)} + * @deprecated Use {@link #setCookieCustomizer(Consumer)} instead. */ + @Deprecated public void setCookieDomain(String cookieDomain) { this.cookieDomain = cookieDomain; } + /** + * Method is copied from {@link CookieCsrfTokenRepository#setSecure(Boolean)} + * @deprecated Use {@link #setCookieCustomizer(Consumer)} instead. + */ + @Deprecated + public void setSecure(Boolean secure) { + this.secure = secure; + } + + /** + * Method is copied from {@link CookieCsrfTokenRepository#setCookieMaxAge(int)} + * @deprecated Use {@link #setCookieCustomizer(Consumer)} instead. + */ + @Deprecated + public void setCookieMaxAge(int cookieMaxAge) { + Assert.isTrue(cookieMaxAge != 0, "cookieMaxAge cannot be zero"); + this.cookieMaxAge = cookieMaxAge; + } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfTokenRequestHandler.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfTokenRequestHandler.java new file mode 100644 index 0000000000..f8af92fa58 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/DSpaceCsrfTokenRequestHandler.java @@ -0,0 +1,66 @@ +/** + * 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 java.util.function.Supplier; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.util.StringUtils; + +/** + * A custom Spring Security CsrfTokenRequestAttributeHandler which uses the Spring Security BREACH protection + * (provided by XorCsrfTokenRequestAttributeHandler) *only* when the CSRF token is sent as a "_csrf" request parameter. + * In all other scenarios, the CsrfTokenRequestAttributeHandler is used instead. + *

+ * NOTE: The DSpace UI always sends the CSRF Token as a request header. It does NOT send it as a "_csrf" request + * paramter. So, this BREACH protection would ONLY be triggered for custom clients (not the DSpace UI). + * Therefore, if using this custom class becomes problematic, we could revert to using the default + * CsrfTokenRequestAttributeHandler without any negative impact on the DSpace UI. + *

+ * This code is copied from the example "SpaCsrfTokenRequestHandler" (for single page applications) from the Spring + * Security docs at + * https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa-configuration + */ +public final class DSpaceCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler { + private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, Supplier csrfToken) { + /* + * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of + * the CsrfToken when it is rendered in the response body. + * NOTE: This should never occur from the DSpace UI, so it is only applicable for custom clients. + */ + this.delegate.handle(request, response, csrfToken); + } + + @Override + public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { + /* + * If the request contains a request header, use CsrfTokenRequestAttributeHandler + * to resolve the CsrfToken. This applies to the DSpace UI which always includes + * the raw CsrfToken in an HTTP Header. + */ + if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) { + return super.resolveCsrfTokenValue(request, csrfToken); + } + /* + * In all other cases (e.g. if the request contains a request parameter), use + * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies + * when a server-side rendered form includes the _csrf request parameter as a + * hidden input. + * NOTE: This should never occur from the DSpace UI, so it is only applicable for custom clients. + */ + return this.delegate.resolveCsrfTokenValue(request, csrfToken); + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java index 0f9b83a4de..6fb8f285d1 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java @@ -28,7 +28,6 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; 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; @@ -112,8 +111,8 @@ public class WebSecurityConfiguration { // While we primarily use JWT in headers, CSRF protection is needed because we also support JWT via Cookies .csrf((csrf) -> csrf .csrfTokenRepository(this.csrfTokenRepository()) - .sessionAuthenticationStrategy(this.sessionAuthenticationStrategy()) - ) + .sessionAuthenticationStrategy(this.dSpaceCsrfAuthenticationStrategy()) + .csrfTokenRequestHandler(new DSpaceCsrfTokenRequestHandler())) .exceptionHandling((exceptionHandling) -> exceptionHandling // Return 401 on authorization failures with a correct WWWW-Authenticate header .authenticationEntryPoint(new DSpace401AuthenticationEntryPoint(restAuthenticationService)) @@ -187,8 +186,13 @@ public class WebSecurityConfiguration { /** * 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. + * + * This is defined as a bean so that it can also be used in other code to reset CSRF Tokens, see + * JWTTokenRestAuthenticationServiceImpl */ - private SessionAuthenticationStrategy sessionAuthenticationStrategy() { + @Lazy + @Bean + public DSpaceCsrfAuthenticationStrategy dSpaceCsrfAuthenticationStrategy() { return new DSpaceCsrfAuthenticationStrategy(csrfTokenRepository()); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java index 059e9aada4..a2928fc96f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java @@ -22,6 +22,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.model.wrapper.AuthenticationToken; import org.dspace.app.rest.security.DSpaceAuthentication; +import org.dspace.app.rest.security.DSpaceCsrfAuthenticationStrategy; import org.dspace.app.rest.security.RestAuthenticationService; import org.dspace.app.rest.utils.ContextUtil; import org.dspace.authenticate.AuthenticationMethod; @@ -33,8 +34,6 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.http.ResponseCookie; -import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.security.web.csrf.CsrfTokenRepository; import org.springframework.stereotype.Component; /** @@ -67,7 +66,7 @@ public class JWTTokenRestAuthenticationServiceImpl implements RestAuthentication @Lazy @Autowired - private CsrfTokenRepository csrfTokenRepository; + private DSpaceCsrfAuthenticationStrategy dspaceCsrfAuthenticationStrategy; @Override public void afterPropertiesSet() throws Exception { @@ -332,11 +331,8 @@ public class JWTTokenRestAuthenticationServiceImpl implements RestAuthentication * @param response current response */ private void resetCSRFToken(HttpServletRequest request, HttpServletResponse response) { - // Remove current CSRF token & generate a new one - // We do this as we want the token to change anytime you login or logout - csrfTokenRepository.saveToken(null, request, response); - CsrfToken newToken = csrfTokenRepository.generateToken(request); - csrfTokenRepository.saveToken(newToken, request, response); + // Use our custom CsrfAuthenticationStrategy class to force reset the CSRF token in Spring Security + dspaceCsrfAuthenticationStrategy.resetCSRFToken(request, response); } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepositoryTest.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepositoryTest.java index 391b60a0c1..95ce6bb37f 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepositoryTest.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/DSpaceCsrfTokenRepositoryTest.java @@ -8,8 +8,10 @@ package org.dspace.app.rest.security; import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import jakarta.servlet.http.Cookie; import jakarta.ws.rs.core.HttpHeaders; @@ -20,10 +22,11 @@ import org.mockito.junit.MockitoJUnitRunner; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.DeferredCsrfToken; /** - * This is almost an exact copy of Spring Security's CookieCsrfTokenRepositoryTests - * https://github.com/spring-projects/spring-security/blob/5.2.x/web/src/test/java/org/springframework/security/web/csrf/CookieCsrfTokenRepositoryTests.java + * This is almost an exact copy of Spring Security's DSpaceCsrfTokenRepositoryTests + * https://github.com/spring-projects/spring-security/blob/6.2.x/web/src/test/java/org/springframework/security/web/csrf/CookieCsrfTokenRepositoryTests.java * * The only modifications are: * - Updating these tests to use our custom DSpaceCsrfTokenRepository @@ -46,12 +49,9 @@ public class DSpaceCsrfTokenRepositoryTest { @Test public void generateToken() { CsrfToken generateToken = this.repository.generateToken(this.request); - assertThat(generateToken).isNotNull(); - assertThat(generateToken.getHeaderName()) - .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME); - assertThat(generateToken.getParameterName()) - .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME); + assertThat(generateToken.getHeaderName()).isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_HEADER_NAME); + assertThat(generateToken.getParameterName()).isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_PARAMETER_NAME); assertThat(generateToken.getToken()).isNotEmpty(); } @@ -61,9 +61,7 @@ public class DSpaceCsrfTokenRepositoryTest { String parameterName = "paramName"; this.repository.setHeaderName(headerName); this.repository.setParameterName(parameterName); - CsrfToken generateToken = this.repository.generateToken(this.request); - assertThat(generateToken).isNotNull(); assertThat(generateToken.getHeaderName()).isEqualTo(headerName); assertThat(generateToken.getParameterName()).isEqualTo(parameterName); @@ -74,17 +72,21 @@ public class DSpaceCsrfTokenRepositoryTest { public void saveToken() { CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getMaxAge()).isEqualTo(-1); - assertThat(tokenCookie.getName()) - .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getName()).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()); - assertThat(tokenCookie.isHttpOnly()).isEqualTo(true); + assertThat(tokenCookie.isHttpOnly()).isTrue(); + } + + @Test + public void saveTokenShouldUseResponseAddCookie() { + CsrfToken token = this.repository.generateToken(this.request); + MockHttpServletResponse spyResponse = spy(this.response); + this.repository.saveToken(token, this.request, spyResponse); + verify(spyResponse).addCookie(any(Cookie.class)); } @Test @@ -92,30 +94,73 @@ public class DSpaceCsrfTokenRepositoryTest { this.request.setSecure(true); CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getSecure()).isTrue(); - // DSpace Custom assert to verify SameSite attribute is set - // The Cookie class doesn't yet support SameSite, so we have to re-read - // the cookie from our headers, and check it. - List headers = this.response.getHeaders(HttpHeaders.SET_COOKIE); - assertThat(headers.size()).isEqualTo(1); - assertThat(headers.get(0)).containsIgnoringCase("SameSite=None"); + + // DSpace Custom assert to verify SameSite attribute is "None" when cookie is secure + assertThat(tokenCookie.getAttribute("SameSite")).containsIgnoringCase("None"); + } + + // Custom test for DSpace to verify behavior for non-secure requests + @Test + public void saveTokenNotSecure() { + this.request.setSecure(false); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getSecure()).isFalse(); + + // DSpace Custom assert to verify SameSite attribute is "Lax" when cookie is NOT secure + assertThat(tokenCookie.getAttribute("SameSite")).containsIgnoringCase("Lax"); + } + + @Test + public void saveTokenSecureFlagTrue() { + this.request.setSecure(false); + this.repository.setSecure(Boolean.TRUE); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getSecure()).isTrue(); + } + + @Test + public void saveTokenSecureFlagTrueUsingCustomizer() { + this.request.setSecure(false); + this.repository.setCookieCustomizer((customizer) -> customizer.secure(Boolean.TRUE)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getSecure()).isTrue(); + } + + @Test + public void saveTokenSecureFlagFalse() { + this.request.setSecure(true); + this.repository.setSecure(Boolean.FALSE); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getSecure()).isFalse(); + } + + @Test + public void saveTokenSecureFlagFalseUsingCustomizer() { + this.request.setSecure(true); + this.repository.setCookieCustomizer((customizer) -> customizer.secure(Boolean.FALSE)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getSecure()).isFalse(); } @Test public void saveTokenNull() { this.request.setSecure(true); this.repository.saveToken(null, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getMaxAge()).isZero(); - assertThat(tokenCookie.getName()) - .isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getName()).isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure()); assertThat(tokenCookie.getValue()).isEmpty(); @@ -126,10 +171,16 @@ public class DSpaceCsrfTokenRepositoryTest { this.repository.setCookieHttpOnly(true); CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.isHttpOnly()).isTrue(); + } - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + @Test + public void saveTokenHttpOnlyTrueUsingCustomizer() { + this.repository.setCookieCustomizer((customizer) -> customizer.httpOnly(true)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.isHttpOnly()).isTrue(); } @@ -138,10 +189,16 @@ public class DSpaceCsrfTokenRepositoryTest { this.repository.setCookieHttpOnly(false); CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.isHttpOnly()).isFalse(); + } - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + @Test + public void saveTokenHttpOnlyFalseUsingCustomizer() { + this.repository.setCookieCustomizer((customizer) -> customizer.httpOnly(false)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.isHttpOnly()).isFalse(); } @@ -150,10 +207,7 @@ public class DSpaceCsrfTokenRepositoryTest { this.repository = DSpaceCsrfTokenRepository.withHttpOnlyFalse(); CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.isHttpOnly()).isFalse(); } @@ -163,10 +217,7 @@ public class DSpaceCsrfTokenRepositoryTest { this.repository.setCookiePath(customPath); CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getPath()).isEqualTo(this.repository.getCookiePath()); } @@ -176,10 +227,7 @@ public class DSpaceCsrfTokenRepositoryTest { this.repository.setCookiePath(customPath); CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); } @@ -189,10 +237,7 @@ public class DSpaceCsrfTokenRepositoryTest { this.repository.setCookiePath(customPath); CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); } @@ -200,16 +245,82 @@ public class DSpaceCsrfTokenRepositoryTest { public void saveTokenWithCookieDomain() { String domainName = "example.com"; this.repository.setCookieDomain(domainName); - CsrfToken token = this.repository.generateToken(this.request); this.repository.saveToken(token, this.request, this.response); - - Cookie tokenCookie = this.response - .getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); - + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); assertThat(tokenCookie.getDomain()).isEqualTo(domainName); } + @Test + public void saveTokenWithCookieDomainUsingCustomizer() { + String domainName = "example.com"; + this.repository.setCookieCustomizer((customizer) -> customizer.domain(domainName)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getDomain()).isEqualTo(domainName); + } + + @Test + public void saveTokenWithCookieMaxAge() { + int maxAge = 1200; + this.repository.setCookieMaxAge(maxAge); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getMaxAge()).isEqualTo(maxAge); + } + + @Test + public void saveTokenWithCookieMaxAgeUsingCustomizer() { + int maxAge = 1200; + this.repository.setCookieCustomizer((customizer) -> customizer.maxAge(maxAge)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getMaxAge()).isEqualTo(maxAge); + } + + @Test + public void saveTokenWithSameSiteNull() { + String sameSitePolicy = null; + this.repository.setCookieCustomizer((customizer) -> customizer.sameSite(sameSitePolicy)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getAttribute("SameSite")).isNull(); + } + + @Test + public void saveTokenWithSameSiteStrict() { + String sameSitePolicy = "Strict"; + this.repository.setCookieCustomizer((customizer) -> customizer.sameSite(sameSitePolicy)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getAttribute("SameSite")).isEqualTo(sameSitePolicy); + } + + @Test + public void saveTokenWithSameSiteLax() { + String sameSitePolicy = "Lax"; + this.repository.setCookieCustomizer((customizer) -> customizer.sameSite(sameSitePolicy)); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getAttribute("SameSite")).isEqualTo(sameSitePolicy); + } + + @Test + public void saveTokenWithExistingSetCookieThenDoesNotOverwrite() { + this.response.setHeader(HttpHeaders.SET_COOKIE, "MyCookie=test"); + this.repository = new DSpaceCsrfTokenRepository(); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + assertThat(this.response.getCookie("MyCookie")).isNotNull(); + assertThat(this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME)).isNotNull(); + } + @Test public void loadTokenNoCookiesNull() { assertThat(this.repository.loadToken(this.request)).isNull(); @@ -218,32 +329,24 @@ public class DSpaceCsrfTokenRepositoryTest { @Test public void loadTokenCookieIncorrectNameNull() { this.request.setCookies(new Cookie("other", "name")); - assertThat(this.repository.loadToken(this.request)).isNull(); } @Test public void loadTokenCookieValueEmptyString() { - this.request.setCookies( - new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, "")); - + this.request.setCookies(new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, "")); assertThat(this.repository.loadToken(this.request)).isNull(); } @Test public void loadToken() { CsrfToken generateToken = this.repository.generateToken(this.request); - this.request - .setCookies(new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, - generateToken.getToken())); - + .setCookies(new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, generateToken.getToken())); CsrfToken loadToken = this.repository.loadToken(this.request); - assertThat(loadToken).isNotNull(); assertThat(loadToken.getHeaderName()).isEqualTo(generateToken.getHeaderName()); - assertThat(loadToken.getParameterName()) - .isEqualTo(generateToken.getParameterName()); + assertThat(loadToken.getParameterName()).isEqualTo(generateToken.getParameterName()); assertThat(loadToken.getToken()).isNotEmpty(); } @@ -256,32 +359,95 @@ public class DSpaceCsrfTokenRepositoryTest { this.repository.setHeaderName(headerName); this.repository.setParameterName(parameterName); this.repository.setCookieName(cookieName); - this.request.setCookies(new Cookie(cookieName, value)); - CsrfToken loadToken = this.repository.loadToken(this.request); - assertThat(loadToken).isNotNull(); assertThat(loadToken.getHeaderName()).isEqualTo(headerName); assertThat(loadToken.getParameterName()).isEqualTo(parameterName); assertThat(loadToken.getToken()).isEqualTo(value); } - @Test(expected = IllegalArgumentException.class) + @Test + public void loadDeferredTokenWhenDoesNotExistThenGeneratedAndSaved() { + DeferredCsrfToken deferredCsrfToken = this.repository.loadDeferredToken(this.request, this.response); + CsrfToken csrfToken = deferredCsrfToken.get(); + assertThat(csrfToken).isNotNull(); + assertThat(deferredCsrfToken.isGenerated()).isTrue(); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie).isNotNull(); + assertThat(tokenCookie.getMaxAge()).isEqualTo(-1); + assertThat(tokenCookie.getName()).isEqualTo(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie.getPath()).isEqualTo(this.request.getContextPath()); + assertThat(tokenCookie.getSecure()).isEqualTo(this.request.isSecure()); + assertThat(tokenCookie.getValue()).isEqualTo(csrfToken.getToken()); + assertThat(tokenCookie.isHttpOnly()).isEqualTo(true); + } + + @Test + public void loadDeferredTokenWhenExistsAndNullSavedThenGeneratedAndSaved() { + CsrfToken generatedToken = this.repository.generateToken(this.request); + this.request + .setCookies(new Cookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, generatedToken.getToken())); + this.repository.saveToken(null, this.request, this.response); + DeferredCsrfToken deferredCsrfToken = this.repository.loadDeferredToken(this.request, this.response); + CsrfToken csrfToken = deferredCsrfToken.get(); + assertThat(csrfToken).isNotNull(); + assertThat(generatedToken).isNotEqualTo(csrfToken); + assertThat(deferredCsrfToken.isGenerated()).isTrue(); + } + + @Test + public void cookieCustomizer() { + String domainName = "example.com"; + String customPath = "/custompath"; + String sameSitePolicy = "Strict"; + this.repository.setCookieCustomizer((customizer) -> { + customizer.domain(domainName); + customizer.secure(false); + customizer.path(customPath); + customizer.sameSite(sameSitePolicy); + }); + CsrfToken token = this.repository.generateToken(this.request); + this.repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie).isNotNull(); + assertThat(tokenCookie.getMaxAge()).isEqualTo(-1); + assertThat(tokenCookie.getDomain()).isEqualTo(domainName); + assertThat(tokenCookie.getPath()).isEqualTo(customPath); + assertThat(tokenCookie.isHttpOnly()).isEqualTo(Boolean.TRUE); + assertThat(tokenCookie.getAttribute("SameSite")).isEqualTo(sameSitePolicy); + } + + @Test + public void withHttpOnlyFalseWhenCookieCustomizerThenStillDefaultsToFalse() { + DSpaceCsrfTokenRepository repository = DSpaceCsrfTokenRepository.withHttpOnlyFalse(); + repository.setCookieCustomizer((customizer) -> customizer.maxAge(1000)); + CsrfToken token = repository.generateToken(this.request); + repository.saveToken(token, this.request, this.response); + Cookie tokenCookie = this.response.getCookie(DSpaceCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME); + assertThat(tokenCookie).isNotNull(); + assertThat(tokenCookie.getMaxAge()).isEqualTo(1000); + assertThat(tokenCookie.isHttpOnly()).isEqualTo(Boolean.FALSE); + } + + @Test public void setCookieNameNullIllegalArgumentException() { - this.repository.setCookieName(null); + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setCookieName(null)); } - @Test(expected = IllegalArgumentException.class) + @Test public void setParameterNameNullIllegalArgumentException() { - this.repository.setParameterName(null); + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setParameterName(null)); } - @Test(expected = IllegalArgumentException.class) + @Test public void setHeaderNameNullIllegalArgumentException() { - this.repository.setHeaderName(null); + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setHeaderName(null)); } - + @Test + public void setCookieMaxAgeZeroIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setCookieMaxAge(0)); + } }