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 <[email protected]>
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(() -> {

Reply via email to