[java-opensaml] branch main updated: Move OIDC revocation cache into main storage module.

Scott Cantor cantor.2 at osu.edu
Wed Jan 26 00:37:50 UTC 2022


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

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

View the commit online:
http://git.shibboleth.net/view/?p=java-opensaml.git;a=commit;h=428b8dbc2d3bc608aff37eda6444252abe2277e9

The following commit(s) were added to refs/heads/main by this push:
     new 428b8dbc2 Move OIDC revocation cache into main storage module.
428b8dbc2 is described below

commit 428b8dbc2d3bc608aff37eda6444252abe2277e9
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Jan 25 19:37:47 2022 -0500

    Move OIDC revocation cache into main storage module.
---
 .../java/org/opensaml/storage/RevocationCache.java | 224 +++++++++++++++++++++
 .../opensaml/storage/impl/RevocationCacheTest.java | 165 +++++++++++++++
 2 files changed, 389 insertions(+)

diff --git a/opensaml-storage-api/src/main/java/org/opensaml/storage/RevocationCache.java b/opensaml-storage-api/src/main/java/org/opensaml/storage/RevocationCache.java
new file mode 100644
index 000000000..9da28035c
--- /dev/null
+++ b/opensaml-storage-api/src/main/java/org/opensaml/storage/RevocationCache.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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.opensaml.storage;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.annotation.constraint.Positive;
+import net.shibboleth.utilities.java.support.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.utilities.java.support.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+import net.shibboleth.utilities.java.support.component.ComponentSupport;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+
+import org.apache.commons.codec.digest.DigestUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Stores and checks for revocation entries.
+ * 
+ * <p>
+ * This class is thread-safe and uses a synchronized method to prevent race conditions within the underlying store
+ * (lacking an atomic "check and insert" operation).
+ * </p>
+ * 
+ * @since 4.2.0
+ */
+ at ThreadSafeAfterInit
+public class RevocationCache extends AbstractIdentifiableInitializableComponent {
+
+    /** Logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(RevocationCache.class);
+
+    /** Backing storage for the replay cache. */
+    @NonnullAfterInit private StorageService storage;
+
+    /** Flag controlling behavior on storage failure. */
+    private boolean strict;
+
+    /** Default lifetime of revocation entry. Default value: 6 hours */
+    @Nonnull @Positive private Duration expires;
+
+    /**
+     * Constructor.
+     */
+    public RevocationCache() {
+        expires = Duration.ofHours(6);
+    }
+
+    /**
+     * Set the default revocation entry expiration.
+     * 
+     * @param entryExpiration lifetime of an revocation entry in milliseconds
+     */
+    public void setEntryExpiration(@Positive final Duration entryExpiration) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        
+        Constraint.isTrue(entryExpiration != null && !entryExpiration.isNegative() && !entryExpiration.isZero(),
+                "Revocation cache default entry expiration must be greater than 0");
+        expires = entryExpiration;
+    }
+
+    /**
+     * Get the backing store for the cache.
+     * 
+     * @return the backing store.
+     */
+    @NonnullAfterInit public StorageService getStorage() {
+        return storage;
+    }
+
+    /**
+     * Set the backing store for the cache.
+     * 
+     * @param storageService backing store to use
+     */
+    public void setStorage(@Nonnull final StorageService storageService) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+
+        storage = Constraint.isNotNull(storageService, "StorageService cannot be null");
+        final StorageCapabilities caps = storage.getCapabilities();
+        if (caps instanceof StorageCapabilitiesEx) {
+            Constraint.isTrue(((StorageCapabilitiesEx) caps).isServerSide(), "StorageService cannot be client-side");
+        }
+    }
+
+    /**
+     * Get the strictness flag.
+     * 
+     * @return true iff we should treat storage failures as a replay
+     */
+    public boolean isStrict() {
+        return strict;
+    }
+
+    /**
+     * Set the strictness flag.
+     * 
+     * @param flag true iff we should treat storage failures as a replay
+     */
+    public void setStrict(final boolean flag) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+
+        strict = flag;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void doInitialize() throws ComponentInitializationException {
+        if (storage == null) {
+            throw new ComponentInitializationException("StorageService cannot be null");
+        }
+    }
+    
+
+    /**
+     * Invokes {@link #revoke(String, String, Duration)} with a default expiration parameter.
+     * 
+     * @param context a context label to subdivide the cache
+     * @param s value to revoke
+     * 
+     * @return true if value has successfully been listed as revoked in the cache.
+     */
+    public synchronized boolean revoke(@Nonnull @NotEmpty final String context, @Nonnull @NotEmpty final String s) {
+        return revoke(context, s, expires);
+    }    
+
+    /**
+     * Returns true if the value is successfully revoked. If value has already been revoked, expiration is updated.
+     * 
+     * @param context a context label to subdivide the cache
+     * @param s value to revoke
+     * @param exp entry expiration
+     * 
+     * @return true if value has successfully been listed as revoked in the cache.
+     */
+    public synchronized boolean revoke(@Nonnull @NotEmpty final String context, @Nonnull @NotEmpty final String s,
+            @Nonnull final Duration exp) {
+        final String key;
+
+        final StorageCapabilities caps = storage.getCapabilities();
+        if (context.length() > caps.getContextSize()) {
+            log.error("context {} too long for StorageService (limit {})", context, caps.getContextSize());
+            return false;
+        } else if (s.length() > caps.getKeySize()) {
+            key = DigestUtils.sha1Hex(s);
+        } else {
+            key = s;
+        }
+        try {
+            final StorageRecord<?> entry = storage.read(context, key);
+            if (entry == null) {
+                log.debug("Entry '{}' of context '{}' is not yet on list of revoked entries,"
+                        + " adding to cache with expiration time {}", key, context, expires);
+                storage.create(context, key, "y", Instant.now().plus(exp).toEpochMilli());
+                return true;
+            }
+            
+            storage.update(context, key, "y", Instant.now().plus(exp).toEpochMilli());
+            log.debug("Entry '{}' of context '{}' was already revoked, updating expiration", key, context);
+            return true;
+        } catch (final IOException e) {
+            log.error("Exception reading/writing to storage service, returning {}", strict ? "failure" : "success", e);
+            return !strict;
+        }
+    }
+
+    /**
+     * Returns false if the value has successfully been confirmed as not revoked.
+     * 
+     * @param context a context label to subdivide the cache
+     * @param s value to revoke
+     * 
+     * @return false if the check value is not found in the cache
+     */
+    public synchronized boolean isRevoked(@Nonnull @NotEmpty final String context, @Nonnull @NotEmpty final String s) {
+        final String key;
+        final StorageCapabilities caps = storage.getCapabilities();
+        if (context.length() > caps.getContextSize()) {
+            log.error("context {} too long for StorageService (limit {})", context, caps.getContextSize());
+            return true;
+        } else if (s.length() > caps.getKeySize()) {
+            key = DigestUtils.sha1Hex(s);
+        } else {
+            key = s;
+        }
+
+        try {
+            final StorageRecord<?> entry = storage.read(context, key);
+            if (entry == null) {
+                log.debug("Entry '{}' is not revoked", key);
+                return false;
+            }
+            
+            log.debug("Entry '{}' is revoked", s);
+            return true;
+        } catch (final IOException e) {
+            log.error("Exception reading/writing to storage service, returning {}", strict ? "failure" : "success", e);
+            return !strict;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/opensaml-storage-impl/src/test/java/org/opensaml/storage/impl/RevocationCacheTest.java b/opensaml-storage-impl/src/test/java/org/opensaml/storage/impl/RevocationCacheTest.java
new file mode 100644
index 000000000..b2e27fa76
--- /dev/null
+++ b/opensaml-storage-impl/src/test/java/org/opensaml/storage/impl/RevocationCacheTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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.opensaml.storage.impl;
+
+import java.nio.charset.Charset;
+import java.time.Duration;
+import java.util.Random;
+
+import org.opensaml.storage.impl.client.ClientStorageService;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+import net.shibboleth.utilities.java.support.logic.ConstraintViolationException;
+
+import org.testng.annotations.BeforeMethod;
+import org.testng.Assert;
+import org.opensaml.storage.RevocationCache;
+
+/**
+ * Tests for {@link RevocationCache}
+ */
+public class RevocationCacheTest {
+
+    
+    private MemoryStorageService storageService;
+    
+    private RevocationCache revocationCache;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+    
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.initialize();
+        
+        revocationCache = new RevocationCache();
+        revocationCache.setEntryExpiration(Duration.ofMillis(500));
+        revocationCache.setStorage(storageService);
+        revocationCache.initialize();
+    }
+    
+    @AfterMethod
+    protected void tearDown() {
+        revocationCache.destroy();
+        revocationCache = null;
+        
+        storageService.destroy();
+        storageService = null;
+    }
+    
+    @Test
+    public void testInit() {
+        revocationCache = new RevocationCache();
+        try {
+            revocationCache.setStorage(null);
+            Assert.fail("Null StorageService should have caused constraint violation");
+        } catch (final Exception e) {
+        }
+
+        try {
+            revocationCache.setStorage(new ClientStorageService());
+            
+            Assert.fail("ClientStorageService should have caused constraint violation");
+        } catch (final Exception e) {
+        }
+    }
+    
+    
+    @Test
+    public void testStrictSetter() throws ComponentInitializationException {
+        Assert.assertFalse(revocationCache.isStrict());
+        revocationCache = new RevocationCache();
+        revocationCache.setStorage(storageService);
+        revocationCache.setStrict(true);
+        revocationCache.initialize();
+        Assert.assertTrue(revocationCache.isStrict());
+    }
+    
+    @Test (expectedExceptions = ConstraintViolationException.class)
+    public void testExpirationSetter() throws ComponentInitializationException {
+        //Must be positive
+        revocationCache = new RevocationCache();
+        revocationCache.setEntryExpiration(Duration.ZERO);
+    }
+    
+    @Test 
+    public void testStorageGetter() throws ComponentInitializationException {
+        Assert.assertEquals(storageService, revocationCache.getStorage());
+    }
+    
+    @Test 
+    public void testRevocationSuccess() throws ComponentInitializationException {
+        Assert.assertFalse(revocationCache.isRevoked("context", "item"));
+        Assert.assertTrue(revocationCache.revoke("context", "item"));
+        Assert.assertTrue(revocationCache.isRevoked("context", "item"));
+    }
+    
+    @Test 
+    public void testRevocationSuccessLongContext() throws ComponentInitializationException {
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.setContextSize(50);
+        storageService.initialize();
+        
+        revocationCache = new RevocationCache();
+        revocationCache.setStorage(storageService);
+        revocationCache.initialize();
+        
+        final byte[] array = new byte[storageService.getCapabilities().getContextSize()*2];
+        new Random().nextBytes(array);
+        final String context = new String(array, Charset.forName("UTF-8"));
+        Assert.assertTrue(context.length()>storageService.getCapabilities().getContextSize());
+        Assert.assertTrue(revocationCache.isRevoked(context, "item"));
+        Assert.assertFalse(revocationCache.revoke(context, "item"));
+    }
+    
+    @Test 
+    public void testRevocationSuccessLongLongItem() throws ComponentInitializationException {
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.setKeySize(50);
+        storageService.initialize();
+        revocationCache = new RevocationCache();
+        revocationCache.setStorage(storageService);
+        revocationCache.initialize();
+        final byte[] array = new byte[storageService.getCapabilities().getKeySize()*2];
+        new Random().nextBytes(array);
+        final String item = new String(array, Charset.forName("UTF-8"));
+        Assert.assertTrue(item.length()>storageService.getCapabilities().getKeySize());
+        Assert.assertFalse(revocationCache.isRevoked("context", item));
+        Assert.assertTrue(revocationCache.revoke("context", item));
+        Assert.assertTrue(revocationCache.isRevoked("context", item));
+    }
+    
+    @Test 
+    public void testRevocationExpirationSuccess() throws ComponentInitializationException, InterruptedException {
+        //Test expiration of entry (500ms)
+        Assert.assertFalse(revocationCache.isRevoked("context", "item"));
+        Assert.assertTrue(revocationCache.revoke("context", "item"));
+        Thread.sleep(600L);
+        Assert.assertFalse(revocationCache.isRevoked("context", "item"));
+        //Test rolling window, second revoke updates expiration past original 500ms
+        Assert.assertTrue(revocationCache.revoke("context", "item"));
+        Thread.sleep(300L);
+        Assert.assertTrue(revocationCache.revoke("context", "item"));
+        Thread.sleep(300L);
+        Assert.assertTrue(revocationCache.isRevoked("context", "item"));
+    }
+}
\ 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