Merge pull request #67 from boris-petrov/same-site-cookies

[SHIRO-722] Add SameSite option to cookies
diff --git a/web/src/main/java/org/apache/shiro/web/servlet/Cookie.java b/web/src/main/java/org/apache/shiro/web/servlet/Cookie.java
index 065b51d..94b18a0 100644
--- a/web/src/main/java/org/apache/shiro/web/servlet/Cookie.java
+++ b/web/src/main/java/org/apache/shiro/web/servlet/Cookie.java
@@ -47,6 +47,12 @@
      */
     public static final String ROOT_PATH = "/";
 
+    public enum SameSiteOptions {
+        NONE,
+        LAX,
+        STRICT,
+    }
+
     String getName();
 
     void setName(String name);
@@ -83,6 +89,10 @@
 
     boolean isHttpOnly();
 
+    void setSameSite(SameSiteOptions sameSite);
+
+    SameSiteOptions getSameSite();
+
     void saveTo(HttpServletRequest request, HttpServletResponse response);
 
     void removeFrom(HttpServletRequest request, HttpServletResponse response);
diff --git a/web/src/main/java/org/apache/shiro/web/servlet/SimpleCookie.java b/web/src/main/java/org/apache/shiro/web/servlet/SimpleCookie.java
index c8d1420..7022586 100644
--- a/web/src/main/java/org/apache/shiro/web/servlet/SimpleCookie.java
+++ b/web/src/main/java/org/apache/shiro/web/servlet/SimpleCookie.java
@@ -67,6 +67,7 @@
     protected static final String COMMENT_ATTRIBUTE_NAME = "Comment";
     protected static final String SECURE_ATTRIBUTE_NAME = "Secure";
     protected static final String HTTP_ONLY_ATTRIBUTE_NAME = "HttpOnly";
+    protected static final String SAME_SITE_ATTRIBUTE_NAME = "SameSite";
 
     private static final transient Logger log = LoggerFactory.getLogger(SimpleCookie.class);
 
@@ -79,11 +80,13 @@
     private int version;
     private boolean secure;
     private boolean httpOnly;
+    private SameSiteOptions sameSite;
 
     public SimpleCookie() {
         this.maxAge = DEFAULT_MAX_AGE;
         this.version = DEFAULT_VERSION;
         this.httpOnly = true; //most of the cookies ever used by Shiro should be as secure as possible.
+        this.sameSite = SameSiteOptions.LAX;
     }
 
     public SimpleCookie(String name) {
@@ -101,6 +104,7 @@
         this.version = Math.max(DEFAULT_VERSION, cookie.getVersion());
         this.secure = cookie.isSecure();
         this.httpOnly = cookie.isHttpOnly();
+        this.sameSite = cookie.getSameSite();
     }
 
     public String getName() {
@@ -178,6 +182,14 @@
         this.httpOnly = httpOnly;
     }
 
+    public SameSiteOptions getSameSite() {
+        return sameSite;
+    }
+
+    public void setSameSite(SameSiteOptions sameSite) {
+        this.sameSite = sameSite;
+    }
+
     /**
      * Returns the Cookie's calculated path setting.  If the {@link javax.servlet.http.Cookie#getPath() path} is {@code null}, then the
      * {@code request}'s {@link javax.servlet.http.HttpServletRequest#getContextPath() context path}
@@ -211,15 +223,16 @@
         int version = getVersion();
         boolean secure = isSecure();
         boolean httpOnly = isHttpOnly();
+        SameSiteOptions sameSite = getSameSite();
 
-        addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly);
+        addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);
     }
 
     private void addCookieHeader(HttpServletResponse response, String name, String value, String comment,
                                  String domain, String path, int maxAge, int version,
-                                 boolean secure, boolean httpOnly) {
+                                 boolean secure, boolean httpOnly, SameSiteOptions sameSite) {
 
-        String headerValue = buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly);
+        String headerValue = buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);
         response.addHeader(COOKIE_HEADER_NAME, headerValue);
 
         if (log.isDebugEnabled()) {
@@ -238,6 +251,13 @@
                                       String domain, String path, int maxAge, int version,
                                       boolean secure, boolean httpOnly) {
 
+        return buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly, getSameSite());
+    }
+
+    protected String buildHeaderValue(String name, String value, String comment,
+                                      String domain, String path, int maxAge, int version,
+                                      boolean secure, boolean httpOnly, SameSiteOptions sameSite) {
+
         if (!StringUtils.hasText(name)) {
             throw new IllegalStateException("Cookie name cannot be null/empty.");
         }
@@ -255,6 +275,7 @@
         appendVersion(sb, version);
         appendSecure(sb, secure);
         appendHttpOnly(sb, httpOnly);
+        appendSameSite(sb, sameSite);
 
         return sb.toString();
 
@@ -328,6 +349,13 @@
         }
     }
 
+    private void appendSameSite(StringBuilder sb, SameSiteOptions sameSite) {
+        if (sameSite != null) {
+            sb.append(ATTRIBUTE_DELIMITER);
+            sb.append(SAME_SITE_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(sameSite.toString().toLowerCase(Locale.ENGLISH));
+        }
+    }
+
     /**
      * Check whether the given {@code cookiePath} matches the {@code requestPath}
      *
@@ -369,8 +397,9 @@
         int version = getVersion();
         boolean secure = isSecure();
         boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all
+        SameSiteOptions sameSite = null;
 
-        addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly);
+        addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);
 
         log.trace("Removed '{}' cookie by setting maxAge=0", name);
     }
diff --git a/web/src/test/groovy/org/apache/shiro/web/session/mgt/DefaultWebSessionManagerTest.groovy b/web/src/test/groovy/org/apache/shiro/web/session/mgt/DefaultWebSessionManagerTest.groovy
index 526636c..841569f 100644
--- a/web/src/test/groovy/org/apache/shiro/web/session/mgt/DefaultWebSessionManagerTest.groovy
+++ b/web/src/test/groovy/org/apache/shiro/web/session/mgt/DefaultWebSessionManagerTest.groovy
@@ -78,6 +78,7 @@
         expect(cookie.getVersion()).andReturn(SimpleCookie.DEFAULT_VERSION);
         expect(cookie.isSecure()).andReturn(true);
         expect(cookie.isHttpOnly()).andReturn(true);
+        expect(cookie.getSameSite()).andReturn(Cookie.SameSiteOptions.LAX);
 
         replay(cookie);
 
diff --git a/web/src/test/java/org/apache/shiro/web/mgt/CookieRememberMeManagerTest.java b/web/src/test/java/org/apache/shiro/web/mgt/CookieRememberMeManagerTest.java
index fba8f55..81d9c5a 100644
--- a/web/src/test/java/org/apache/shiro/web/mgt/CookieRememberMeManagerTest.java
+++ b/web/src/test/java/org/apache/shiro/web/mgt/CookieRememberMeManagerTest.java
@@ -71,6 +71,7 @@
         expect(cookie.getVersion()).andReturn(SimpleCookie.DEFAULT_VERSION);
         expect(cookie.isSecure()).andReturn(false);
         expect(cookie.isHttpOnly()).andReturn(true);
+        expect(cookie.getSameSite()).andReturn(org.apache.shiro.web.servlet.Cookie.SameSiteOptions.LAX);
 
         UsernamePasswordToken token = new UsernamePasswordToken("user", "secret");
         token.setRememberMe(true);
diff --git a/web/src/test/java/org/apache/shiro/web/servlet/SimpleCookieTest.java b/web/src/test/java/org/apache/shiro/web/servlet/SimpleCookieTest.java
index 3a272aa..4d26f86 100644
--- a/web/src/test/java/org/apache/shiro/web/servlet/SimpleCookieTest.java
+++ b/web/src/test/java/org/apache/shiro/web/servlet/SimpleCookieTest.java
@@ -25,6 +25,7 @@
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.util.Locale;
 
 import static org.easymock.EasyMock.*;
 
@@ -59,7 +60,7 @@
         String path = "/somepath";
 
         String headerValue = this.cookie.buildHeaderValue(name, value, null, null, path,
-                0, SimpleCookie.DEFAULT_VERSION, false, false);
+                0, SimpleCookie.DEFAULT_VERSION, false, false, null);
 
         String expectedStart = new StringBuilder()
                 .append(name).append(SimpleCookie.NAME_VALUE_DELIMITER).append(value)
@@ -89,6 +90,9 @@
                 .append(SimpleCookie.PATH_ATTRIBUTE_NAME).append(SimpleCookie.NAME_VALUE_DELIMITER).append(Cookie.ROOT_PATH)
                 .append(SimpleCookie.ATTRIBUTE_DELIMITER)
                 .append(SimpleCookie.HTTP_ONLY_ATTRIBUTE_NAME)
+                .append(SimpleCookie.ATTRIBUTE_DELIMITER)
+                .append(SimpleCookie.SAME_SITE_ATTRIBUTE_NAME).append(SimpleCookie.NAME_VALUE_DELIMITER)
+                    .append(Cookie.SameSiteOptions.LAX.toString().toLowerCase(Locale.ENGLISH))
                 .toString();
 
         expect(mockRequest.getContextPath()).andReturn(contextPath);