You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@shiro.apache.org by lp...@apache.org on 2026/06/20 19:28:25 UTC

(shiro) branch main updated (88568741e -> e441ea370)

This is an automated email from the ASF dual-hosted git repository.

lprimak pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git


    from 88568741e chore: update Payara version and refactor port property names in configuration, and make dependabot see payara for updates
     new 42aa34467 bugfix(guice-web): now matching paths with trailing slash stripped
     new e441ea370 enh(web): rememberMe cookie expiration is checked on the server

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../shiro/mgt/AbstractRememberMeManager.java       | 77 ++++++++++++++++------
 .../lang/io/ClassResolvingObjectInputStream.java   | 12 +++-
 .../apache/shiro/lang/io/DefaultSerializer.java    |  8 ++-
 .../org/apache/shiro/lang/util/ClassUtils.java     | 27 ++++++--
 .../shiro/guice/web/SimpleFilterChainResolver.java | 41 +++++++-----
 .../shiro/guice/web/PathMatcherBypassTest.java     | 55 ++++++++++++++++
 .../guice/web/SimpleFilterChainResolverTest.java   |  8 +--
 .../mgt/PathMatchingFilterChainResolver.java       | 22 ++++---
 .../shiro/web/mgt/CookieRememberMeManager.java     | 30 ++++++++-
 .../shiro/web/mgt/CookieRememberMeManagerTest.java | 76 +++++++++++++++++----
 10 files changed, 284 insertions(+), 72 deletions(-)
 create mode 100644 support/guice/src/test/java/org/apache/shiro/guice/web/PathMatcherBypassTest.java


(shiro) 02/02: enh(web): rememberMe cookie expiration is checked on the server

Posted by lp...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lprimak pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git

commit e441ea37009a426b122c53b0120ae5879d00e354
Author: lprimak <le...@flowlogix.com>
AuthorDate: Sat Jun 20 14:03:37 2026 -0500

    enh(web): rememberMe cookie expiration is checked on the server
---
 .../shiro/mgt/AbstractRememberMeManager.java       | 77 ++++++++++++++++------
 .../lang/io/ClassResolvingObjectInputStream.java   | 12 +++-
 .../apache/shiro/lang/io/DefaultSerializer.java    |  8 ++-
 .../org/apache/shiro/lang/util/ClassUtils.java     | 27 ++++++--
 .../shiro/web/mgt/CookieRememberMeManager.java     | 30 ++++++++-
 .../shiro/web/mgt/CookieRememberMeManagerTest.java | 76 +++++++++++++++++----
 6 files changed, 186 insertions(+), 44 deletions(-)

diff --git a/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java b/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
index e1ea74bfc..da4de8962 100644
--- a/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
+++ b/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
@@ -25,17 +25,25 @@ import org.apache.shiro.authc.RememberMeAuthenticationToken;
 import org.apache.shiro.crypto.cipher.AesCipherService;
 import org.apache.shiro.crypto.cipher.ByteSourceBroker;
 import org.apache.shiro.crypto.cipher.CipherService;
+import org.apache.shiro.lang.io.ClassResolvingObjectInputStream;
 import org.apache.shiro.lang.io.DefaultSerializer;
 import org.apache.shiro.lang.io.Serializer;
 import org.apache.shiro.lang.util.ByteSource;
 import org.apache.shiro.lang.util.ByteUtils;
-import org.apache.shiro.lang.util.ClassUtils;
+import org.apache.shiro.lang.util.ClassUtils.ClassLoaderAccessor;
+import org.apache.shiro.lang.util.ClassUtils.ExceptionIgnoringAccessor;
 import org.apache.shiro.subject.PrincipalCollection;
 import org.apache.shiro.subject.Subject;
 import org.apache.shiro.subject.SubjectContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.Instant;
 import java.util.function.Supplier;
 
 /**
@@ -66,16 +74,32 @@ import java.util.function.Supplier;
  * @since 0.9
  */
 public abstract class AbstractRememberMeManager implements RememberMeManager {
+    protected record RememberedIdentity(PrincipalCollection principals, Instant creationTime) implements Serializable {
+        @Serial
+        private static final long serialVersionUID = 1L;
+    }
 
     /**
      * private inner log instance.
      */
     private static final Logger LOGGER = LoggerFactory.getLogger(AbstractRememberMeManager.class);
 
+    private static final ClassLoaderAccessor ADDITIONAL_CL_ACCESSOR = new ExceptionIgnoringAccessor() {
+        @Override
+        protected ClassLoader doGetClassLoader() {
+            return AbstractRememberMeManager.class.getClassLoader();
+        }
+    };
+
     /**
      * Serializer to use for converting PrincipalCollection instances to/from byte arrays
      */
-    private Serializer<PrincipalCollection> serializer = new DefaultSerializer<>();
+    private Serializer<RememberedIdentity> serializer = new DefaultSerializer<>() {
+        @Override
+        protected ObjectInputStream createObjectInputStream(InputStream inputStream) throws IOException {
+            return new ClassResolvingObjectInputStream(inputStream, ADDITIONAL_CL_ACCESSOR);
+        }
+    };
 
     /**
      * Cipher to use for encrypting/decrypting serialized byte arrays for added security
@@ -121,7 +145,7 @@ public abstract class AbstractRememberMeManager implements RememberMeManager {
      * @return the {@code Serializer} used to serialize and deserialize {@link PrincipalCollection} instances for
      * persistent remember me storage.
      */
-    public Serializer<PrincipalCollection> getSerializer() {
+    public Serializer<RememberedIdentity> getSerializer() {
         return serializer;
     }
 
@@ -134,7 +158,7 @@ public abstract class AbstractRememberMeManager implements RememberMeManager {
      * @param serializer the {@code Serializer} used to serialize and deserialize {@link PrincipalCollection} instances
      *                   for persistent remember me storage.
      */
-    public void setSerializer(Serializer<PrincipalCollection> serializer) {
+    public void setSerializer(Serializer<RememberedIdentity> serializer) {
         this.serializer = serializer;
     }
 
@@ -352,14 +376,14 @@ public abstract class AbstractRememberMeManager implements RememberMeManager {
     /**
      * Converts the given principal collection the byte array that will be persisted to be 'remembered' later.
      * <p/>
-     * This implementation first {@link #serialize(org.apache.shiro.subject.PrincipalCollection) serializes} the
+     * This implementation first {@link #serialize(RememberedIdentity) serializes} the
      * principals to a byte array and then {@link #encrypt(byte[]) encrypts} that byte array.
      *
      * @param principals the {@code PrincipalCollection} to convert to a byte array
      * @return the representative byte array to be persisted for remember me functionality.
      */
     protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
-        byte[] bytes = serialize(principals);
+        byte[] bytes = serialize(new RememberedIdentity(principals, now()));
         if (getCipherService() != null) {
             bytes = encrypt(bytes);
         }
@@ -433,7 +457,28 @@ public abstract class AbstractRememberMeManager implements RememberMeManager {
         if (getCipherService() != null) {
             bytes = decrypt(bytes);
         }
-        return deserialize(bytes);
+        RememberedIdentity remembered = deserialize(bytes);
+        return checkExpiration(remembered);
+    }
+
+    /**
+     * Checks the given remembered identity for expiration.  The default implementation does not perform any expiration
+     * checks and simply returns the principals.  Subclasses can override this method to perform expiration checks based on the
+     * {@code creationTime} property of the {@code RememberedIdentity}
+     * @param remembered identity
+     * @return PrincipalCollection
+     */
+    protected PrincipalCollection checkExpiration(RememberedIdentity remembered) {
+        return remembered.principals();
+    }
+
+    /**
+     * Returns the current time as an {@link Instant}.  Subclasses can override this method to provide a different time,
+     * such as for testing
+     * @return the current time as an {@link Instant}
+     */
+    protected Instant now() {
+        return Instant.now();
     }
 
     /**
@@ -508,13 +553,8 @@ public abstract class AbstractRememberMeManager implements RememberMeManager {
      * @param principals the principal collection to serialize to a byte array
      * @return the serialized principal collection in the form of a byte array
      */
-    protected byte[] serialize(PrincipalCollection principals) {
-        ClassUtils.setAdditionalClassLoader(AbstractRememberMeManager.class.getClassLoader());
-        try {
-            return getSerializer().serialize(principals);
-        } finally {
-            ClassUtils.removeAdditionalClassLoader();
-        }
+    protected byte[] serialize(RememberedIdentity principals) {
+        return getSerializer().serialize(principals);
     }
 
     /**
@@ -524,13 +564,8 @@ public abstract class AbstractRememberMeManager implements RememberMeManager {
      * @param serializedIdentity the previously serialized {@code PrincipalCollection} as a byte array
      * @return the deserialized (reconstituted) {@code PrincipalCollection}
      */
-    protected PrincipalCollection deserialize(byte[] serializedIdentity) {
-        ClassUtils.setAdditionalClassLoader(AbstractRememberMeManager.class.getClassLoader());
-        try {
-            return getSerializer().deserialize(serializedIdentity);
-        } finally {
-            ClassUtils.removeAdditionalClassLoader();
-        }
+    protected RememberedIdentity deserialize(byte[] serializedIdentity) {
+        return getSerializer().deserialize(serializedIdentity);
     }
 
     /**
diff --git a/lang/src/main/java/org/apache/shiro/lang/io/ClassResolvingObjectInputStream.java b/lang/src/main/java/org/apache/shiro/lang/io/ClassResolvingObjectInputStream.java
index 1f7f0c73d..538f92b6e 100644
--- a/lang/src/main/java/org/apache/shiro/lang/io/ClassResolvingObjectInputStream.java
+++ b/lang/src/main/java/org/apache/shiro/lang/io/ClassResolvingObjectInputStream.java
@@ -19,6 +19,7 @@
 package org.apache.shiro.lang.io;
 
 import org.apache.shiro.lang.util.ClassUtils;
+import org.apache.shiro.lang.util.ClassUtils.ClassLoaderAccessor;
 import org.apache.shiro.lang.util.UnknownClassException;
 
 import java.io.IOException;
@@ -33,9 +34,16 @@ import java.io.ObjectStreamClass;
  * @since 1.2
  */
 public class ClassResolvingObjectInputStream extends ObjectInputStream {
+    private final ClassLoaderAccessor additionalClassLoader;
 
     public ClassResolvingObjectInputStream(InputStream inputStream) throws IOException {
+        this(inputStream, null);
+    }
+
+    public ClassResolvingObjectInputStream(InputStream inputStream,
+                                           ClassLoaderAccessor additionalClassLoader) throws IOException {
         super(inputStream);
+        this.additionalClassLoader = additionalClassLoader;
     }
 
     /**
@@ -48,9 +56,9 @@ public class ClassResolvingObjectInputStream extends ObjectInputStream {
      * @throws ClassNotFoundException if the class could not be found in any known ClassLoader
      */
     @Override
-    protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
+    protected Class<?> resolveClass(ObjectStreamClass osc) throws ClassNotFoundException {
         try {
-            return ClassUtils.forName(osc.getName());
+            return ClassUtils.forName(osc.getName(), additionalClassLoader);
         } catch (UnknownClassException e) {
             throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", e);
         }
diff --git a/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java b/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java
index a06f7a142..ba6d89041 100644
--- a/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java
+++ b/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java
@@ -23,6 +23,7 @@ import java.io.BufferedOutputStream;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 
@@ -33,7 +34,6 @@ import java.io.ObjectOutputStream;
  * @since 0.9
  */
 public class DefaultSerializer<T> implements Serializer<T> {
-
     /**
      * This implementation serializes the Object by using an {@link ObjectOutputStream} backed by a
      * {@link ByteArrayOutputStream}.  The {@code ByteArrayOutputStream}'s backing byte array is returned.
@@ -80,7 +80,7 @@ public class DefaultSerializer<T> implements Serializer<T> {
         ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
         BufferedInputStream bis = new BufferedInputStream(bais);
         try {
-            ObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
+            ObjectInputStream ois = createObjectInputStream(bis);
             @SuppressWarnings({"unchecked"})
             T deserialized = (T) ois.readObject();
             ois.close();
@@ -90,4 +90,8 @@ public class DefaultSerializer<T> implements Serializer<T> {
             throw new SerializationException(msg, e);
         }
     }
+
+    protected ObjectInputStream createObjectInputStream(InputStream inputStream) throws IOException {
+        return new ClassResolvingObjectInputStream(inputStream);
+    }
 }
diff --git a/lang/src/main/java/org/apache/shiro/lang/util/ClassUtils.java b/lang/src/main/java/org/apache/shiro/lang/util/ClassUtils.java
index 2a9b8243b..ef00fadd1 100644
--- a/lang/src/main/java/org/apache/shiro/lang/util/ClassUtils.java
+++ b/lang/src/main/java/org/apache/shiro/lang/util/ClassUtils.java
@@ -194,8 +194,25 @@ public final class ClassUtils {
      * @return the located class
      * @throws UnknownClassException if the class cannot be found.
      */
-    @SuppressWarnings("unchecked")
     public static <T> Class<T> forName(String fqcn) throws UnknownClassException {
+        return forName(fqcn, null);
+    }
+
+    /**
+     * Attempts to load the specified class name from the current thread's
+     * {@link Thread#getContextClassLoader() context class loader}, then the
+     * current ClassLoader (<code>ClassUtils.class.getClassLoader()</code>), then the system/application
+     * ClassLoader (<code>ClassLoader.getSystemClassLoader()</code>, in that order.  If any of them cannot locate
+     * the specified class, an <code>UnknownClassException</code> is thrown (our RuntimeException equivalent of
+     * the JRE's <code>ClassNotFoundException</code>.
+     *
+     * @param fqcn the fully qualified class name to load
+     * @param additionalClassLoader accessor to override additional class loader
+     * @return the located class
+     * @throws UnknownClassException if the class cannot be found.
+     */
+    @SuppressWarnings("unchecked")
+    public static <T> Class<T> forName(String fqcn, ClassLoaderAccessor additionalClassLoader) throws UnknownClassException {
         Class<?> clazz = THREAD_CL_ACCESSOR.loadClass(fqcn);
 
         if (clazz == null) {
@@ -211,7 +228,8 @@ public final class ClassUtils {
                 LOGGER.trace("Unable to load class named [" + fqcn
                         + "] from the org.apache.shiro.lang ClassLoader.  Trying the additionally set ClassLoader...");
             }
-            clazz = ADDITIONAL_CL_ACCESSOR.loadClass(fqcn);
+            clazz = additionalClassLoader != null ? additionalClassLoader.loadClass(fqcn)
+                    : ADDITIONAL_CL_ACCESSOR.loadClass(fqcn);
         }
 
         if (clazz == null) {
@@ -337,7 +355,7 @@ public final class ClassUtils {
     /**
      * @since 1.0
      */
-    private interface ClassLoaderAccessor {
+    public interface ClassLoaderAccessor {
         Class<?> loadClass(String fqcn);
 
         InputStream getResourceStream(String name);
@@ -348,8 +366,7 @@ public final class ClassUtils {
     /**
      * @since 1.0
      */
-    private abstract static class ExceptionIgnoringAccessor implements ClassLoaderAccessor {
-
+    public abstract static class ExceptionIgnoringAccessor implements ClassLoaderAccessor {
         public Class<?> loadClass(String fqcn) {
             Class<?> clazz = null;
             ClassLoader cl = getClassLoader();
diff --git a/web/src/main/java/org/apache/shiro/web/mgt/CookieRememberMeManager.java b/web/src/main/java/org/apache/shiro/web/mgt/CookieRememberMeManager.java
index 8f83b432d..d84f85f83 100644
--- a/web/src/main/java/org/apache/shiro/web/mgt/CookieRememberMeManager.java
+++ b/web/src/main/java/org/apache/shiro/web/mgt/CookieRememberMeManager.java
@@ -18,10 +18,12 @@
  */
 package org.apache.shiro.web.mgt;
 
+import java.time.Instant;
 import java.util.function.Supplier;
 
 import org.apache.shiro.lang.codec.Base64;
 import org.apache.shiro.mgt.AbstractRememberMeManager;
+import org.apache.shiro.subject.PrincipalCollection;
 import org.apache.shiro.subject.Subject;
 import org.apache.shiro.subject.SubjectContext;
 import org.apache.shiro.web.servlet.Cookie;
@@ -38,7 +40,6 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import static org.apache.shiro.session.mgt.DefaultSessionManager.SECURE_COOKIE_DISABLED;
 
-
 /**
  * Remembers a Subject's identity by saving the Subject's {@link Subject#getPrincipals() principals} to a {@link Cookie}
  * for later retrieval.
@@ -72,7 +73,6 @@ import static org.apache.shiro.session.mgt.DefaultSessionManager.SECURE_COOKIE_D
  * @since 1.0
  */
 public class CookieRememberMeManager extends AbstractRememberMeManager {
-
     /**
      * The default name of the underlying rememberMe cookie which is {@code rememberMe}.
      */
@@ -139,6 +139,7 @@ public class CookieRememberMeManager extends AbstractRememberMeManager {
      * @param subject    the Subject for which the identity is being serialized.
      * @param serialized the serialized bytes to be persisted.
      */
+    @Override
     protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {
 
         if (!WebUtils.isHttp(subject)) {
@@ -191,6 +192,7 @@ public class CookieRememberMeManager extends AbstractRememberMeManager {
      *                       lookup.
      * @return a previously serialized identity byte array or {@code null} if the byte array could not be acquired.
      */
+    @Override
     protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
 
         if (!WebUtils.isHttp(subjectContext)) {
@@ -246,6 +248,28 @@ public class CookieRememberMeManager extends AbstractRememberMeManager {
         }
     }
 
+    @Override
+    protected PrincipalCollection checkExpiration(RememberedIdentity identity) {
+        int maxAge = getCookie().getMaxAge();
+
+        // Negative maxAge means no explicit persistence lifetime is configured
+        // (e.g. session cookie semantics), so nothing to enforce here.
+        if (maxAge < 0) {
+            return identity.principals();
+        }
+
+        Instant expiresAt = identity.creationTime().plusSeconds(maxAge);
+        if (now().isAfter(expiresAt)) {
+            if (LOGGER.isDebugEnabled()) {
+                LOGGER.debug("Remembered identity expired at [{}] based on cookie maxAge [{}] seconds.",
+                        expiresAt, maxAge);
+            }
+            return null;
+        }
+
+        return identity.principals();
+    }
+
     /**
      * Sometimes a user agent will send the rememberMe cookie value without padding,
      * most likely because {@code =} is a separator in the cookie header.
@@ -276,6 +300,7 @@ public class CookieRememberMeManager extends AbstractRememberMeManager {
      *
      * @param subject the subject instance for which identity data should be forgotten from the underlying persistence
      */
+    @Override
     protected void forgetIdentity(Subject subject) {
         if (WebUtils.isHttp(subject)) {
             HttpServletRequest request = WebUtils.getHttpRequest(subject);
@@ -293,6 +318,7 @@ public class CookieRememberMeManager extends AbstractRememberMeManager {
      *
      * @param subjectContext the contextual data, usually provided by a {@link Subject.Builder} implementation
      */
+    @Override
     public void forgetIdentity(SubjectContext subjectContext) {
         if (WebUtils.isHttp(subjectContext)) {
             HttpServletRequest request = WebUtils.getHttpRequest(subjectContext);
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 e28d6037a..60043aecf 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
@@ -35,7 +35,9 @@ import jakarta.servlet.http.Cookie;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 
+import java.time.Instant;
 import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;
@@ -54,6 +56,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
  * @since 1.0
  */
 class CookieRememberMeManagerTest {
+    //The following base64 string was determined from the log output of the above 'onSuccessfulLogin' test.
+    //This will have to change any time the PrincipalCollection implementation changes:
+    @SuppressWarnings("checkstyle:ConstantName")
+    private static
+    final String userPCAesBase64 = "nKSpN8HWb1LN9LpM/Q78UDuKDHpqTBOVH651SqQfhAfdGbm3XyWBwjUesqQVX+c5xe8ewLiqXtcBE"
+            + "DfAnmnMWNPPVKNtHrAGN081gSd/UGuCDIIjy7JnlKQNFe7hkVSAQAaNEyOYKKs5mZtaOQiuzieTAIp2DaFJdwZ5eKodZIBN8Tc"
+            + "rDG9S5DWW6kEHOSW37Vxk+FRL3My1/bDh4VTa84lYChAEUdBLBxnTPYXQrVt9HAQavqRhE1KJiXcku+LEE0DzqtE3qkaO1C6vH"
+            + "It2gmzri8ULdFnWQr0C0dXSe0KkcMDfpuEwluMQOAjSsLjv0W066xZkV6FYWV6KpFQjSUDRyJODL8t/q3aP5bNA73FCq7eXLc2"
+            + "p98bn1ord/Q8yIYts4MtXMiJpjVW8zM6CBbc1L2ipQnwqH5NcRDSk3HcGq61/COzy5oAJHN1yIoowMfRhV+in1z/N2PANjzw6H"
+            + "hZslDvZo4bMwSu+qwpiXQ1z3vGtdqX8W+ch0GaivJSXjP3gC6CpMn+WeYJL+rp5y9lRj4LOoAIVHKnYEn7zjp26W/l7dks5eyo"
+            + "l6GwlUsHy3KtKigdepfE5WCUmXk6aBBQOSS7cMMmHjDJnOf+dgtkvSbxCZySAXCQi4ONgSrX8wBFuGCTEdKUDUogAAJ5HpZ0Fs"
+            + "BdaUE3NYaeo5ptXbzFfCFUosqu0";
+    private static final String CIPHER_KEY = "kPH+bIxk5D2deZiIxcaaaA==";
 
     @Test
     void onSuccessfulLogin() {
@@ -65,6 +80,7 @@ class CookieRememberMeManagerTest {
         expect(mockSubject.getServletResponse()).andReturn(mockResponse).anyTimes();
 
         CookieRememberMeManager mgr = new CookieRememberMeManager();
+        mgr.setCipherKey(Base64.decode(CIPHER_KEY));
         org.apache.shiro.web.servlet.Cookie cookie = createMock(org.apache.shiro.web.servlet.Cookie.class);
         mgr.setCookie(cookie);
 
@@ -133,16 +149,6 @@ class CookieRememberMeManagerTest {
 
         expect(mockRequest.getAttribute(ShiroHttpServletRequest.IDENTITY_REMOVED_KEY)).andReturn(null);
 
-        //The following base64 string was determined from the log output of the above 'onSuccessfulLogin' test.
-        //This will have to change any time the PrincipalCollection implementation changes:
-        final String userPCAesBase64 = "0o6DCfePYTjK4q579qzUFEfkeGRvbBOdKHp2y8/nGAltt1Vz8uW0Z8igeO"
-                 + "Tq/yBmcw25f3Q0ui/Leg3x0iQZWhw9Bbu0mFHmHsGxEd6mPwtUpSegIjyX5c/kZpqnb7QLdajPWiczX8P"
-                 + "Oc2Eku5+8ye1u38Y8uKlklHxcYCPh0pRiDSBxfjPsLaDfOpGbmPjZd4SVg68i/++TvUjqBNJyb+pDix3f"
-                 + "PeuPvReWGcE50iovezVZrEfDOAQ0cZYW35ShypMWOmE9yZnb+p8++StDyAUegryyuIa4pjuRzfMh9D+sN"
-                 + "F9tm/EnDC1VCer2S/a0AGlWAQiM7jrWt1sNinZcKIrvShaWI21tONJt8WhozNS2H72lk4p92rfLNHeglT"
-                 + "xObxIYxLfTI9KiToSe1nYmpQmbBO8x1wWDkWBG//EqRvhgbIfQVqJp12T0fJC1nFuZuVhw/ZanaAZGDk8"
-                 + "7aLMiw3T6FBZtWaspgvfH+0TJrTD8Ra386ekNXNN8JW8=";
-
         Cookie[] cookies = new Cookie[] {
                 new Cookie(CookieRememberMeManager.DEFAULT_REMEMBER_ME_COOKIE_NAME, userPCAesBase64)
         };
@@ -150,8 +156,14 @@ class CookieRememberMeManagerTest {
         expect(mockRequest.getCookies()).andReturn(cookies);
         replay(mockRequest);
 
-        CookieRememberMeManager mgr = new CookieRememberMeManager();
-        mgr.setCipherKey(Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="));
+        CookieRememberMeManager mgr = new CookieRememberMeManager() {
+            @Override
+            protected Instant now() {
+                // Return a fixed time to ensure the test is repeatable and not affected by time-based expiration
+                return Instant.parse("2021-01-01T00:00:00Z");
+            }
+        };
+        mgr.setCipherKey(Base64.decode(CIPHER_KEY));
         PrincipalCollection collection = mgr.getRememberedPrincipals(context);
 
         verify(mockRequest);
@@ -161,6 +173,46 @@ class CookieRememberMeManagerTest {
         assertThat(collection.iterator().next()).isEqualTo("user");
     }
 
+    @Test
+    void ensureRememberMeExpires() {
+        HttpServletRequest mockRequest = createMock(HttpServletRequest.class);
+        HttpServletResponse mockResponse = createMock(HttpServletResponse.class);
+        WebSubjectContext context = new DefaultWebSubjectContext();
+        context.setServletRequest(mockRequest);
+        context.setServletResponse(mockResponse);
+
+        AtomicReference<Instant> instant = new AtomicReference<>();
+        CookieRememberMeManager mgr = new CookieRememberMeManager() {
+            @Override
+            protected Instant now() {
+                return instant.get();
+            }
+
+            @Override
+            protected PrincipalCollection checkExpiration(RememberedIdentity remembered) {
+                instant.set(remembered.creationTime().plusSeconds(getCookie().getMaxAge() + 1));
+                return super.checkExpiration(remembered);
+            }
+        };
+        mgr.setCipherKey(Base64.decode(CIPHER_KEY));
+        mgr.getCookie().setMaxAge(5);
+
+        expect(mockRequest.getAttribute(ShiroHttpServletRequest.IDENTITY_REMOVED_KEY)).andReturn(null);
+
+        Cookie[] cookies = new Cookie[] {
+                new Cookie(CookieRememberMeManager.DEFAULT_REMEMBER_ME_COOKIE_NAME, userPCAesBase64)
+        };
+
+        expect(mockRequest.getCookies()).andReturn(cookies);
+        replay(mockRequest);
+
+        PrincipalCollection collection = mgr.getRememberedPrincipals(context);
+
+        verify(mockRequest);
+
+        assertThat(collection).isNull();
+    }
+
     @Test
     void getRememberedPrincipalsNoMoreDefaultCipher() {
         assertThatExceptionOfType(CryptoException.class).isThrownBy(() -> {


(shiro) 01/02: bugfix(guice-web): now matching paths with trailing slash stripped

Posted by lp...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lprimak pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git

commit 42aa34467ad30f085fc8e003042db0c397fe8d57
Author: lprimak <le...@flowlogix.com>
AuthorDate: Sat Jun 20 14:00:47 2026 -0500

    bugfix(guice-web): now matching paths with trailing slash stripped
---
 .../shiro/guice/web/SimpleFilterChainResolver.java | 41 +++++++++-------
 .../shiro/guice/web/PathMatcherBypassTest.java     | 55 ++++++++++++++++++++++
 .../guice/web/SimpleFilterChainResolverTest.java   |  8 ++--
 .../mgt/PathMatchingFilterChainResolver.java       | 22 +++++----
 4 files changed, 98 insertions(+), 28 deletions(-)

diff --git a/support/guice/src/main/java/org/apache/shiro/guice/web/SimpleFilterChainResolver.java b/support/guice/src/main/java/org/apache/shiro/guice/web/SimpleFilterChainResolver.java
index bca3a4c79..3981f4db6 100644
--- a/support/guice/src/main/java/org/apache/shiro/guice/web/SimpleFilterChainResolver.java
+++ b/support/guice/src/main/java/org/apache/shiro/guice/web/SimpleFilterChainResolver.java
@@ -33,6 +33,7 @@ import com.google.inject.Key;
 import org.apache.shiro.util.PatternMatcher;
 import org.apache.shiro.web.filter.mgt.FilterChainResolver;
 import org.apache.shiro.web.util.WebUtils;
+import static org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver.removeTrailingSlash;
 
 class SimpleFilterChainResolver implements FilterChainResolver {
     private final Map<String, Key<? extends Filter>[]> chains;
@@ -46,26 +47,34 @@ class SimpleFilterChainResolver implements FilterChainResolver {
     }
 
     public FilterChain getChain(ServletRequest request, ServletResponse response, final FilterChain originalChain) {
-        String path = WebUtils.getPathWithinApplication(WebUtils.toHttp(request));
-        for (final String pathPattern : chains.keySet()) {
-            if (patternMatcher.matches(pathPattern, path)) {
-                final Iterator<Key<? extends Filter>> chain = Arrays.asList(chains.get(pathPattern)).iterator();
-                return new SimpleFilterChain(originalChain, new Iterator<Filter>() {
-                    public boolean hasNext() {
-                        return chain.hasNext();
-                    }
+        String requestURI = WebUtils.getPathWithinApplication(WebUtils.toHttp(request));
+        final String requestURINoTrailingSlash = removeTrailingSlash(requestURI);
 
-                    public Filter next() {
-                        return injector.getInstance(chain.next());
-                    }
-
-                    public void remove() {
-                        throw new UnsupportedOperationException();
-                    }
-                });
+        for (String pathPattern : chains.keySet()) {
+            if (patternMatcher.matches(pathPattern, requestURI)) {
+                return proxy(originalChain, pathPattern);
+            } else {
+                pathPattern = removeTrailingSlash(pathPattern);
+                if (patternMatcher.matches(pathPattern, requestURINoTrailingSlash)) {
+                    return proxy(originalChain, pathPattern);
+                }
             }
         }
         return null;
     }
 
+    private FilterChain proxy(FilterChain originalChain, String pathPattern) {
+        final Iterator<Key<? extends Filter>> chain = Arrays.asList(chains.get(pathPattern)).iterator();
+        return new SimpleFilterChain(originalChain, new Iterator<>() {
+            public boolean hasNext() {
+                return chain.hasNext();
+            }
+            public Filter next() {
+                return injector.getInstance(chain.next());
+            }
+            public void remove() {
+                throw new UnsupportedOperationException();
+            }
+        });
+    }
 }
diff --git a/support/guice/src/test/java/org/apache/shiro/guice/web/PathMatcherBypassTest.java b/support/guice/src/test/java/org/apache/shiro/guice/web/PathMatcherBypassTest.java
new file mode 100644
index 000000000..8b7ff38cb
--- /dev/null
+++ b/support/guice/src/test/java/org/apache/shiro/guice/web/PathMatcherBypassTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.shiro.guice.web;
+
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.shiro.util.AntPathMatcher;
+import org.junit.jupiter.api.Test;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class PathMatcherBypassTest {
+    @Test
+    void guicePathBypass() {
+        AntPathMatcher matcher = new AntPathMatcher();
+        @SuppressWarnings("unchecked")
+        Key<? extends Filter>[] adminChain = (Key<? extends Filter>[]) new Key<?>[]{ Key.get(Filter.class) };
+        Map<String, Key<? extends Filter>[]> chains = new LinkedHashMap<>();
+        chains.put("/admin/foo", adminChain);
+
+        Injector injector = mock(Injector.class);
+        var resolver = new SimpleFilterChainResolver(chains, injector, matcher);
+        FilterChain orig = mock(FilterChain.class);
+
+        // Case 2: trailing slash bypass
+        HttpServletRequest req2 = mock(HttpServletRequest.class);
+        when(req2.getServletPath()).thenReturn("");
+        when(req2.getPathInfo()).thenReturn("/admin/foo/");
+        FilterChain r2 = resolver.getChain(req2, mock(HttpServletResponse.class), orig);
+        assertThat(r2).isNotNull();
+    }
+}
diff --git a/support/guice/src/test/java/org/apache/shiro/guice/web/SimpleFilterChainResolverTest.java b/support/guice/src/test/java/org/apache/shiro/guice/web/SimpleFilterChainResolverTest.java
index e5768f793..24e6d9ee1 100644
--- a/support/guice/src/test/java/org/apache/shiro/guice/web/SimpleFilterChainResolverTest.java
+++ b/support/guice/src/test/java/org/apache/shiro/guice/web/SimpleFilterChainResolverTest.java
@@ -87,7 +87,7 @@ public class SimpleFilterChainResolverTest {
 
         expect(request.getCharacterEncoding()).andStubReturn(null);
 
-        expect(patternMatcher.matches(chainOne, "/mychain")).andReturn(false);
+        expect(patternMatcher.matches(chainOne, "/mychain")).andReturn(false).times(2);
         expect(patternMatcher.matches(chainTwo, "/mychain")).andReturn(true);
 
         Filter filter2a = ctrl.createMock(Filter.class);
@@ -118,9 +118,9 @@ public class SimpleFilterChainResolverTest {
 
         expect(request.getCharacterEncoding()).andStubReturn(null);
 
-        expect(patternMatcher.matches(chainOne, "/nochain")).andReturn(false);
-        expect(patternMatcher.matches(chainTwo, "/nochain")).andReturn(false);
-        expect(patternMatcher.matches(chainThree, "/nochain")).andReturn(false);
+        expect(patternMatcher.matches(chainOne, "/nochain")).andReturn(false).times(2);
+        expect(patternMatcher.matches(chainTwo, "/nochain")).andReturn(false).times(2);
+        expect(patternMatcher.matches(chainThree, "/nochain")).andReturn(false).times(2);
 
         ctrl.replay();
 
diff --git a/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java b/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
index 4a6fbe10a..dda7f4575 100644
--- a/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
+++ b/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
@@ -153,6 +153,20 @@ public class PathMatchingFilterChainResolver implements FilterChainResolver {
         return null;
     }
 
+    /**
+     * Removes the trailing slash from the given path if it exists and is not the root path ("/").
+
+     * @param path input
+     * @return stripped path
+     */
+    public static String removeTrailingSlash(String path) {
+        if (path != null && !DEFAULT_PATH_SEPARATOR.equals(path)
+                && path.endsWith(DEFAULT_PATH_SEPARATOR)) {
+            return path.substring(0, path.length() - 1);
+        }
+        return path;
+    }
+
     /**
      * Returns {@code true} if an incoming request path (the {@code path} argument)
      * matches a configured filter chain path (the {@code pattern} argument), {@code false} otherwise.
@@ -186,12 +200,4 @@ public class PathMatchingFilterChainResolver implements FilterChainResolver {
     protected String getPathWithinApplication(ServletRequest request) {
         return WebUtils.getPathWithinApplication(WebUtils.toHttp(request));
     }
-
-    private static String removeTrailingSlash(String path) {
-        if (path != null && !DEFAULT_PATH_SEPARATOR.equals(path)
-                && path.endsWith(DEFAULT_PATH_SEPARATOR)) {
-            return path.substring(0, path.length() - 1);
-        }
-        return path;
-    }
 }