From dfaba46e86b0f7382d42ffb7e75fc793e2b50252 Mon Sep 17 00:00:00 2001 From: Tamas Mak Date: Fri, 1 Mar 2024 09:07:53 +0100 Subject: [PATCH] Configure the clusterKey Cookie (#110) --- .../starter/KubernetesKitConfiguration.java | 9 +++- .../starter/KubernetesKitProperties.java | 29 ++++++++++++ .../starter/sessiontracker/SameSite.java | 34 ++++++++++++++ .../sessiontracker/SessionTrackerCookie.java | 11 +++-- .../sessiontracker/SessionTrackerFilter.java | 25 ++++++++++- .../SessionTrackerCookieTest.java | 33 +++++++++----- .../SessionTrackerFilterTest.java | 44 ++++++++++++++++--- 7 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SameSite.java diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitConfiguration.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitConfiguration.java index ca2185b..9dcce0f 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitConfiguration.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitConfiguration.java @@ -74,9 +74,16 @@ public static class VaadinReplicatedSessionConfiguration { private static final Predicate> TRANSIENT_INJECTABLE_VAADIN_EXCLUSIONS = type -> !type .getPackageName().startsWith("com.vaadin.flow.internal"); + final KubernetesKitProperties properties; + + public VaadinReplicatedSessionConfiguration( + KubernetesKitProperties properties) { + this.properties = properties; + } + SessionTrackerFilter sessionTrackerFilter( SessionSerializer sessionSerializer) { - return new SessionTrackerFilter(sessionSerializer); + return new SessionTrackerFilter(sessionSerializer, properties); } SessionListener sessionListener(BackendConnector backendConnector, diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitProperties.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitProperties.java index 998eaf9..312243b 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitProperties.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/KubernetesKitProperties.java @@ -11,6 +11,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; +import com.vaadin.kubernetes.starter.sessiontracker.SameSite; + /** * Definition of configuration properties for the Kubernetes Kit starter. * @@ -30,6 +32,11 @@ public class KubernetesKitProperties { */ private boolean autoConfigure = true; + /** + * Value of the distributed storage session key cookie's SameSite attribute. + */ + private SameSite clusterKeyCookieSameSite = SameSite.STRICT; + /** * Hazelcast configuration properties. */ @@ -57,6 +64,28 @@ public void setAutoConfigure(boolean autoConfigure) { this.autoConfigure = autoConfigure; } + /** + * Gets the distributed storage session key cookie's SameSite attribute + * value. + * + * @return the distributed storage session key cookie's SameSite attribute + * value + */ + public SameSite getClusterKeyCookieSameSite() { + return clusterKeyCookieSameSite; + } + + /** + * Sets the distributed storage session key cookie's SameSite attribute. + * + * @param sameSite + * value of the distributed storage session key cookie's + * SameSite attribute + */ + public void setClusterKeyCookieSameSite(SameSite sameSite) { + this.clusterKeyCookieSameSite = sameSite; + } + /** * Gets Hazelcast configuration properties. * diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SameSite.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SameSite.java new file mode 100644 index 0000000..22dec59 --- /dev/null +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SameSite.java @@ -0,0 +1,34 @@ +package com.vaadin.kubernetes.starter.sessiontracker; + +/** + * Enum for SameSite cookie attribute values. + */ +public enum SameSite { + + /** + * Cookies are sent in both first-party and cross-origin requests. + */ + NONE("None"), + + /** + * Cookies are sent in a first-party context, also when following a link to + * the origin site. + */ + LAX("Lax"), + + /** + * Cookies are only sent in a first-party context (i.e. not when following a + * link to the origin site). + */ + STRICT("Strict"); + + private final String attributeValue; + + SameSite(String attributeValue) { + this.attributeValue = attributeValue; + } + + public String attributeValue() { + return this.attributeValue; + } +} diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookie.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookie.java index 5daf53c..23f1dfb 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookie.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookie.java @@ -15,6 +15,7 @@ import jakarta.servlet.http.HttpSession; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import java.util.stream.Stream; /** @@ -39,17 +40,19 @@ private SessionTrackerCookie() { * the HTTP response. */ public static void setIfNeeded(HttpSession session, - HttpServletRequest request, HttpServletResponse response) { + HttpServletRequest request, HttpServletResponse response, + Consumer cookieConsumer) { Optional clusterKeyCookie = getCookie(request); - if (!clusterKeyCookie.isPresent()) { + if (clusterKeyCookie.isEmpty()) { String clusterKey = UUID.randomUUID().toString(); session.setAttribute(CurrentKey.COOKIE_NAME, clusterKey); - response.addCookie(new Cookie(CurrentKey.COOKIE_NAME, clusterKey)); + Cookie cookie = new Cookie(CurrentKey.COOKIE_NAME, clusterKey); + cookieConsumer.accept(cookie); + response.addCookie(cookie); } else if (session.getAttribute(CurrentKey.COOKIE_NAME) == null) { String clusterKey = clusterKeyCookie.get().getValue(); session.setAttribute(CurrentKey.COOKIE_NAME, clusterKey); } - } /** diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilter.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilter.java index 08936d9..11a51a7 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilter.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilter.java @@ -11,17 +11,20 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import java.io.IOException; +import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.vaadin.flow.server.HandlerHelper.RequestType; import com.vaadin.flow.shared.ApplicationConstants; +import com.vaadin.kubernetes.starter.KubernetesKitProperties; /** * An HTTP filter implementation that serializes and persists HTTP session on a @@ -45,9 +48,12 @@ public class SessionTrackerFilter extends HttpFilter { private final transient SessionSerializer sessionSerializer; + private final transient KubernetesKitProperties properties; - public SessionTrackerFilter(SessionSerializer sessionSerializer) { + public SessionTrackerFilter(SessionSerializer sessionSerializer, + KubernetesKitProperties properties) { this.sessionSerializer = sessionSerializer; + this.properties = properties; } @Override @@ -67,7 +73,8 @@ protected void doFilter(HttpServletRequest request, HttpSession session = request.getSession(false); if (session != null) { - SessionTrackerCookie.setIfNeeded(session, request, response); + SessionTrackerCookie.setIfNeeded(session, request, response, + cookieConsumer(request)); } super.doFilter(request, response, chain); @@ -82,6 +89,20 @@ protected void doFilter(HttpServletRequest request, } } + private Consumer cookieConsumer(HttpServletRequest request) { + return (Cookie cookie) -> { + cookie.setHttpOnly(true); + + String path = request.getContextPath().isEmpty() ? "/" : request.getContextPath(); + cookie.setPath(path); + + SameSite sameSite = properties.getClusterKeyCookieSameSite(); + if (sameSite != null && !sameSite.attributeValue().isEmpty()) { + cookie.setAttribute("SameSite", sameSite.attributeValue()); + } + }; + } + private Logger getLogger() { return LoggerFactory.getLogger(getClass()); } diff --git a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookieTest.java b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookieTest.java index 7362f0a..5e2c281 100644 --- a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookieTest.java +++ b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerCookieTest.java @@ -1,13 +1,13 @@ package com.vaadin.kubernetes.starter.sessiontracker; +import java.util.Optional; +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.servlet.http.HttpSession; - -import java.util.Optional; -import java.util.UUID; - import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -21,29 +21,38 @@ import static org.mockito.Mockito.when; public class SessionTrackerCookieTest { + @Test - void setIfNeeded_nullCookies_attributeIsSet() { + void setIfNeeded_nullCookies_attributeIsSetAndCookieIsConfigured() { HttpSession session = mock(HttpSession.class); HttpServletRequest request = mock(HttpServletRequest.class); when(request.getCookies()).thenReturn(null); HttpServletResponse response = mock(HttpServletResponse.class); + @SuppressWarnings("unchecked") + Consumer cookieConsumer = (Consumer) mock(Consumer.class); - SessionTrackerCookie.setIfNeeded(session, request, response); + SessionTrackerCookie.setIfNeeded(session, request, response, + cookieConsumer); verify(session).setAttribute(eq(CurrentKey.COOKIE_NAME), anyString()); + verify(cookieConsumer).accept(any()); verify(response).addCookie(any()); } @Test - void setIfNeeded_emptyCookies_attributeIsSet() { + void setIfNeeded_emptyCookies_attributeIsSetAndCookieIsConfigured() { HttpSession session = mock(HttpSession.class); HttpServletRequest request = mock(HttpServletRequest.class); when(request.getCookies()).thenReturn(new Cookie[0]); HttpServletResponse response = mock(HttpServletResponse.class); + @SuppressWarnings("unchecked") + Consumer cookieConsumer = (Consumer) mock(Consumer.class); - SessionTrackerCookie.setIfNeeded(session, request, response); + SessionTrackerCookie.setIfNeeded(session, request, response, + cookieConsumer); verify(session).setAttribute(eq(CurrentKey.COOKIE_NAME), anyString()); + verify(cookieConsumer).accept(any()); verify(response).addCookie(any()); } @@ -57,8 +66,10 @@ void setIfNeeded_nullSessionAttribute_attributeIsSet() { when(request.getCookies()).thenReturn(new Cookie[] { new Cookie(CurrentKey.COOKIE_NAME, clusterKey) }); HttpServletResponse response = mock(HttpServletResponse.class); + Consumer cookieConsumer = (Cookie cookie) -> {}; - SessionTrackerCookie.setIfNeeded(session, request, response); + SessionTrackerCookie.setIfNeeded(session, request, response, + cookieConsumer); verify(session).setAttribute(eq(CurrentKey.COOKIE_NAME), eq(clusterKey)); @@ -75,8 +86,10 @@ void setIfNeeded_nonNullSessionAttribute_attributeIsNotSet() { when(request.getCookies()).thenReturn(new Cookie[] { new Cookie(CurrentKey.COOKIE_NAME, clusterKey) }); HttpServletResponse response = mock(HttpServletResponse.class); + Consumer cookieConsumer = (Cookie cookie) -> {}; - SessionTrackerCookie.setIfNeeded(session, request, response); + SessionTrackerCookie.setIfNeeded(session, request, response, + cookieConsumer); verify(session, never()).setAttribute(any(), any()); verify(response, never()).addCookie(any()); diff --git a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilterTest.java b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilterTest.java index 863d6ad..d322469 100644 --- a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilterTest.java +++ b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionTrackerFilterTest.java @@ -1,30 +1,43 @@ package com.vaadin.kubernetes.starter.sessiontracker; +import java.io.IOException; +import java.util.UUID; +import java.util.function.Consumer; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.UUID; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockHttpSession; import com.vaadin.flow.server.HandlerHelper; import com.vaadin.flow.shared.ApplicationConstants; +import com.vaadin.kubernetes.starter.KubernetesKitProperties; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +@ExtendWith({ MockitoExtension.class }) class SessionTrackerFilterTest { SessionSerializer serializer = mock(SessionSerializer.class); - SessionTrackerFilter filter = new SessionTrackerFilter(serializer); + SessionTrackerFilter filter = new SessionTrackerFilter(serializer, + new KubernetesKitProperties()); HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); @@ -34,6 +47,9 @@ class SessionTrackerFilterTest { Cookie cookie = new Cookie(CurrentKey.COOKIE_NAME, UUID.randomUUID().toString()); + @Captor + private ArgumentCaptor> cookieConsumerArgumentCaptor; + @AfterEach void assertFilterChainIsAlwaysExecuted() throws ServletException, IOException { @@ -117,6 +133,25 @@ void invalidatedHttpSession_UIDLRequest_sessionNotSerialized() .isEqualTo(cookie.getValue()); } + @Test + void validHttpSession_cookieConsumer_configuresCookie() throws Exception { + Cookie cookie = new Cookie("clusterKey", "value"); + setupHttpSession(); + when(request.getContextPath()).thenReturn("contextpath"); + + try (MockedStatic mockedStatic = mockStatic(SessionTrackerCookie.class)) { + filter.doFilter(request, response, filterChain); + mockedStatic.verify(() -> SessionTrackerCookie.setIfNeeded(any(), any(), any(), + cookieConsumerArgumentCaptor.capture())); + Consumer cookieConsumer = cookieConsumerArgumentCaptor.getValue(); + cookieConsumer.accept(cookie); + } + + assertTrue(cookie.isHttpOnly()); + assertEquals("contextpath", cookie.getPath()); + assertEquals("Strict", cookie.getAttribute("SameSite")); + } + private MockHttpSession setupHttpSession() { MockHttpSession httpSession = new MockHttpSession(); when(request.getSession(false)).thenReturn(httpSession); @@ -125,6 +160,5 @@ private MockHttpSession setupHttpSession() { private void setupCookie() { when(request.getCookies()).thenReturn(new Cookie[] { cookie }); - } }