[java-plugin-shibd] branch main updated: Draft of cookie state manager, untested.

Scott Cantor cantor.2 at osu.edu
Tue Aug 13 16:30:37 UTC 2024


This is an automated email from the git hooks/post-receive script.

scantor pushed a commit to branch main
in repository java-plugin-shibd.

View the commit online:
http://git.shibboleth.net/view/?p=java-plugin-shibd.git;a=commit;h=527250d7b77a7655894500bd6135922514ae340d

The following commit(s) were added to refs/heads/main by this push:
     new 527250d  Draft of cookie state manager, untested.
527250d is described below

commit 527250d7b77a7655894500bd6135922514ae340d
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Aug 13 12:30:34 2024 -0400

    Draft of cookie state manager, untested.
---
 .../sp/messaging/RemotedHttpServletRequest.java    |  25 +-
 .../sp/impl/CookieStateTokenManager.java           | 269 +++++++++++++++++++++
 2 files changed, 291 insertions(+), 3 deletions(-)

diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
index bc6d41c..a3ea79d 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
@@ -384,7 +384,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
                                 name.substring(0, name.length() - 7);
                             }
                             assert cookies != null;
-                            cookies.add(new Cookie(name, nvpair[1]));
+                            cookies.add(new SortableCookie(name, nvpair[1]));
                         }
                     }
                 } else {
@@ -590,6 +590,27 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
         throw new UnsupportedOperationException();
     }
     
+    /** A subclass of {@link Cookie} to enable sorting by name. */
+    public class SortableCookie extends Cookie implements Comparable<Cookie> {
+        
+        private static final long serialVersionUID = -5930215369912605949L;
+
+        /**
+         * Constructor.
+         *
+         * @param name cookie name
+         * @param value cookie value
+         */
+        public SortableCookie(@Nonnull final String name, @Nullable final String value) {
+            super(name, value);
+        }
+
+        /** {@inheritDoc} */
+        public int compareTo(final Cookie c) {
+            return getName().compareTo(c.getName());
+        }
+    }
+    
     /**
      * Helper method to decode a byte buffer into either UTF-8 or ISO-8859-1.
      * 
@@ -708,6 +729,4 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
         }
     }
     
-
-    /** {@inheritDoc} */
 }
\ No newline at end of file
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/CookieStateTokenManager.java b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/CookieStateTokenManager.java
new file mode 100644
index 0000000..472e003
--- /dev/null
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/CookieStateTokenManager.java
@@ -0,0 +1,269 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.sp.impl;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Arrays;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.Positive;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.DecodingException;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.sp.AbstractStateTokenManager;
+import net.shibboleth.sp.Agent;
+import net.shibboleth.sp.Application;
+import net.shibboleth.sp.StateTokenManager;
+
+/**
+ * {@link StateTokenManager} implemented using cookies.
+ */
+public class CookieStateTokenManager extends AbstractStateTokenManager {
+    
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(CookieStateTokenManager.class);
+    
+    /** Servlet request supplier. */
+    @NonnullAfterInit private NonnullSupplier<HttpServletRequest> requestSupplier;
+
+    /** Servlet response supplier. */
+    @NonnullAfterInit private NonnullSupplier<HttpServletResponse> responseSupplier;
+
+    /** Fixed prefix for cookie names. */
+    @NonnullAfterInit private String cookiePrefix;
+    
+    /** Limit on number of cookies to retain. */
+    private int cookieLimit;
+    
+    /** Constructor. */
+    public CookieStateTokenManager() {
+        cookieLimit = 10;
+    }
+    
+    /**
+     * Set {@link HttpServletRequest} supplier.
+     * 
+     * @param supplier request supplier
+     */
+    public void setHttpServletRequestSupplier(@Nonnull final NonnullSupplier<HttpServletRequest> supplier) {
+        checkSetterPreconditions();
+        
+        requestSupplier = Constraint.isNotNull(supplier, "HttpServletRequest supplier cannot be null");
+    }
+
+    /**
+     * Set {@link HttpServletRequest} supplier.
+     * 
+     * @param supplier request supplier
+     */
+    public void setHttpServletResponseSupplier(@Nonnull final NonnullSupplier<HttpServletResponse> supplier) {
+        checkSetterPreconditions();
+        
+        responseSupplier = Constraint.isNotNull(supplier, "HttpServletResponse supplier cannot be null");
+    }
+
+    /**
+     * Set the fixed prefix to use for the cookies.
+     * 
+     * @param prefix cookie prefix
+     */
+    public void setCookiePrefix(@Nonnull final String prefix) {
+        checkSetterPreconditions();
+        
+        cookiePrefix = Constraint.isNotNull(StringSupport.trimOrNull(prefix), "Cookie prefix cannot be null or empty");
+    }
+    
+    /**
+     * Set limit on the number of state cookies to permit.
+     * 
+     * <p>Defaults to 10.</p>
+     * 
+     * @param limit limit to set
+     */
+    public void setCookieLimit(@Positive final int limit) {
+        checkSetterPreconditions();
+        
+        cookieLimit = Constraint.isGreaterThan(0, limit, "Cookie limit must be positive");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (requestSupplier == null || responseSupplier == null) {
+            throw new ComponentInitializationException("HttpServletRequest/Response suppliers cannot be null");
+        } else if (cookiePrefix == null) {
+            throw new ComponentInitializationException("Cookie prefix cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull public String preserveToStateToken(@Nonnull final Agent agent, @Nonnull final Application application,
+            @Nonnull final byte[] value) throws IOException {
+
+        purgeStaleCookies(application);
+        
+        final Instant ts = Instant.now();
+        assert ts != null;
+        
+        final String key = ts.toEpochMilli() + '_' + generateToken();
+        
+        Cookie cookie;
+        try {
+            cookie = new Cookie(getCookieName(application, key), Base64Support.encodeURLSafe(value));
+        } catch (final EncodingException e) {
+            throw new IOException(e);
+        }
+        
+        cookie.setMaxAge((int) getExpiration().toSeconds());
+        cookie.setAttribute("SameSite", "none");
+        // TODO: handle other cookie attributes
+
+        responseSupplier.get().addCookie(cookie);
+
+        log.trace("Created state token mapping from '{}' to value '{}'", cookie.getName(), value);
+        
+        return "cookie:" + key;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable public byte[] recoverFromStateToken(@Nonnull final Agent agent, @Nonnull final Application application,
+            @Nonnull final String token) throws IOException {
+        
+        if (token.length() < 8 || !token.startsWith("cookie:")) {
+            log.warn("Invalid state token: '{}'", token);
+            return null;
+        }
+        
+        final String cookieName = getCookieName(application, token.substring(7));
+        
+        final HttpServletRequest request = requestSupplier.get();
+        final Cookie[] cookies = request.getCookies();
+        if (cookies == null) {
+            log.warn("No cookies in request");
+            return null;
+        }
+        
+        for (final Cookie c : cookies) {
+            if (cookieName.equals(c.getName())) {
+                log.trace("Recovered state token mapping from '{}' to value '{}'", token, c.getValue());
+                final Cookie unsetCookie = new Cookie(c.getName(), null);
+                unsetCookie.setMaxAge(0);
+                unsetCookie.setAttribute("SameSite", "none");
+                // TODO: handle other cookie attributes
+                responseSupplier.get().addCookie(unsetCookie);
+                try {
+                    if (c.getValue() != null) {
+                        return Base64Support.decodeURLSafe(c.getValue());
+                    } else {
+                        return null;
+                    }
+                } catch (final DecodingException e) {
+                    throw new IOException(e);
+                }
+            }
+        }
+        
+        log.warn("No cookie found matching state token: '{}'", token);
+        return null;
+    }
+    
+    /**
+     * Scan incoming cookies for any that are over the limit.
+     * 
+     * @param application the application
+     */
+    private void purgeStaleCookies(@Nonnull final Application application) {
+        
+        final HttpServletRequest request = requestSupplier.get();
+        
+        final Cookie[] cookies = request.getCookies();
+        if (cookies == null) {
+            return;
+        }
+        
+        final HttpServletResponse response = responseSupplier.get();
+
+        // Should be possible because we implement Comparable internally.
+        Arrays.sort(cookies);
+
+        int maxCookies = cookieLimit;
+        int purgedCookies = 0;
+        
+        for (int i = cookies.length - 1; i >= 0; --i) {
+            if (!cookies[0].getName().startsWith(cookiePrefix)) {
+                continue;
+            }
+            
+            if (maxCookies > 0) {
+                // Keep it but count against limit.
+                --maxCookies;
+            } else {
+                // We're over the limit, so everything here and older gets cleaned up.
+                final Cookie unsetCookie = new Cookie(cookies[0].getName(), null);
+                unsetCookie.setMaxAge(0);
+                unsetCookie.setAttribute("SameSite", "none");
+                // TODO: handle other cookie attributes
+                response.addCookie(unsetCookie);
+                ++purgedCookies;
+            }
+        }
+        
+        if (purgedCookies > 0) {
+            log.debug("Purged {} stale state token cookie(s)", purgedCookies);
+        }
+    }
+    
+    /**
+     * Computes the name of a new state cookie.
+     * 
+     * @param application the application
+     * @param uniquePortion unique portion of name
+     * 
+     * @return cookie name
+     */
+    @Nonnull private String getCookieName(@Nonnull final Application application, @Nonnull final String uniquePortion) {
+        
+        final StringBuilder builder = new StringBuilder(cookiePrefix);
+        
+        // Format is prefix_appId_timestamp_random
+        // The timestamp allows them to be sorted for staleness.
+        
+        builder.append('_')
+            .append(application.getId())
+            .append('_')
+            .append(uniquePortion);
+        
+        return builder.toString();
+    }
+
+}
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list