[java-idp-plugin-webauthn] branch main updated: JWEBAUTHN-56 - Improve performance of credential lookup by userHandle
Phil Smart
philip.smart at jisc.ac.uk
Mon Jul 21 14:43:08 UTC 2025
This is an automated email from the git hooks/post-receive script.
philsmart pushed a commit to branch main
in repository java-idp-plugin-webauthn.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-webauthn.git;a=commit;h=c41cb28d85b4404616485ceace1b439ca9b30b3a
The following commit(s) were added to refs/heads/main by this push:
new c41cb28 JWEBAUTHN-56 - Improve performance of credential lookup by userHandle
c41cb28 is described below
commit c41cb28d85b4404616485ceace1b439ca9b30b3a
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Jul 4 18:21:05 2025 +0100
JWEBAUTHN-56 - Improve performance of credential lookup by userHandle
- Add cache for secondary index on userHandle and credentialId
- Add optional accelerator service for JSON-based credential lookup.
Enables an accelerator for deployers using RDBMSs with JSON query
support (e.g., MySQL, PostgreSQL). Works alongside the JDBC storage
service to speed up credential searches by userHandle or credentialId.
https://shibboleth.atlassian.net/browse/JWEBAUTHN-56
---
webauthn-impl/pom.xml | 11 +
.../authn/webauthn/storage/impl/CacheService.java | 58 ++
.../webauthn/storage/impl/CacheServiceImpl.java | 219 ++++++++
.../impl/CredentialRegistrationSerializer.java | 2 +-
.../storage/impl/DisabledCacheServiceImpl.java | 50 ++
... => IdPStorageServiceCredentialRepository.java} | 463 ++++++++++++++-
.../webauthn/storage/impl/QueryByAllStrategy.java | 63 +++
.../storage/impl/QueryByCredentialIdStrategy.java | 67 +++
.../storage/impl/QueryByUserHandleStrategy.java | 66 +++
.../storage/impl/ReadAllCacheLoadingStrategy.java | 69 +++
.../StorageServiceCredentialRepositoryFactory.java | 489 ++++++++++++++++
...BasedIdPStorageServiceCredentialRepository.java | 214 +++++++
.../storage/impl/WebAuthnJDBCAccelerator.java | 22 +
.../storage/impl/WebAuthnJDBCAcceleratorImpl.java | 416 ++++++++++++++
.../storage/impl/WebAuthnJDBCQueryAccelerator.java | 71 +++
.../impl/WebAuthnJDBCReadAllAccelerator.java | 45 ++
.../storage/impl/WebAuthnJDBCStorageRecord.java | 51 ++
.../META-INF/net.shibboleth.idp/postconfig.xml | 23 +-
.../authn/webauthn/conf/authn/webauthn.properties | 13 +-
.../idp/plugin/authn/webauthn/plugin.properties | 2 +-
.../webauthn/flow/AbstractWebAuthnFlowTest.java | 25 +-
.../authn/webauthn/flow/TestRegistrationFlow.java | 6 +-
.../authn/webauthn/flow/TestSecondFactorFlow.java | 2 +-
.../authn/webauthn/impl/AbstractWebAuthnTest.java | 36 ++
...IdPStorageServiceCredentialRespositoryTest.java | 625 ++++++++++++++++++---
...dIdPStorageServiceCredentialRepositoryTest.java | 201 +++++++
26 files changed, 3186 insertions(+), 123 deletions(-)
diff --git a/webauthn-impl/pom.xml b/webauthn-impl/pom.xml
index b4962b4..10f56e9 100644
--- a/webauthn-impl/pom.xml
+++ b/webauthn-impl/pom.xml
@@ -62,6 +62,11 @@
<scope>runtime</scope>
</dependency>
<!-- Provided dependencies -->
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>${spring-webflow.groupId}</groupId>
<artifactId>spring-webflow</artifactId>
@@ -262,6 +267,12 @@
<artifactId>idp-ui</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>net.shibboleth.plugin.storage.jdbc</groupId>
+ <artifactId>jdbc-storage-impl</artifactId>
+ <version>2.1.1-SNAPSHOT</version>
+ <scope>provided</scope>
+ </dependency>
<!-- Test dependencies -->
<dependency>
<groupId>${okhttp3.mockserver.groupId}</groupId>
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CacheService.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CacheService.java
new file mode 100644
index 0000000..c8a57bb
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CacheService.java
@@ -0,0 +1,58 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+
+/**
+ * A cache facade that handles the consistent conversation between {@link CredentialRecord}s and cache entries.
+ */
+public interface CacheService {
+
+ /**
+ * Returns the value associated with key in this cache, or {@code null} if there is no
+ * cached value.
+ *
+ * @param key the key to use
+ * @return the username associated with userHandle in the cache, or {@code null} if no mapping is found.
+ */
+ @Nullable String getIfPresent(@Nonnull final ByteArray key);
+
+ /**
+ * Associates the registration with the username in this cache. If the cache previously contained a
+ * value associated with this registration, the old value is replaced by the username in the registration.
+ *
+ * @param registration the registration to extract the mapping from.
+ */
+ void put(@Nonnull final CredentialRecord registration);
+
+ /**
+ * Removes any cached value for the key.
+ *
+ * @param key the key
+ */
+ void invalidate(@Nonnull final ByteArray key);
+
+ /**
+ * Removes all entries in the cache.
+ */
+ void invalidateAll();
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CacheServiceImpl.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CacheServiceImpl.java
new file mode 100644
index 0000000..b285eae
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CacheServiceImpl.java
@@ -0,0 +1,219 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+
+import com.google.common.cache.Cache;
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * An implementation of a {@link CacheService} that handles the consistent conversation between
+ * {@link CredentialRecord}s and username cache entries using the supplied strategies.
+ */
+public final class CacheServiceImpl implements CacheService {
+
+ /** Class logger.*/
+ @Nonnull private final Logger log = LoggerFactory.getLogger(CacheServiceImpl.class);
+
+ /** The mapping cache.*/
+ @Nonnull private final Cache<String, String> cache;
+
+ /** The strategy to use to convert a {@link CredentialRecord} into a key suitable for the cache.*/
+ @Nonnull private final Function<CredentialRecord, String> keyExtractionStrategy;
+
+ /** The strategy to use to convert a ByteArray into a key suitable for the cache.*/
+ @Nonnull private final Function<ByteArray, String> lookupKeyExtractionStrategy;
+
+ /** The strategy to use to convert a {@link CredentialRecord} into a suitable value for the cache.*/
+ @Nonnull private final Function<CredentialRecord, String> valueExtractionStrategy;
+
+ /**
+ *
+ * Constructor.
+ *
+ * @param builder the builder used to construct the class
+ */
+ private CacheServiceImpl(final Builder builder) {
+ this.cache = Constraint.isNotNull(builder.cacheBuild,
+ "Cache can not be null");
+ this.keyExtractionStrategy = Constraint.isNotNull(builder.keyExtractionStrategyBuild,
+ "Key extraction strategy can not be null");
+ this.lookupKeyExtractionStrategy = Constraint.isNotNull(builder.lookupKeyExtractionStrategyBuild,
+ "Lookup key extraction strategy can not be null");
+ this.valueExtractionStrategy = Constraint.isNotNull(builder.valueExtractionStrategyBuild,
+ "Value extraction strategy can not be null");
+ }
+
+ /**
+ * Get the cache, for testing.
+ *
+ * @return the cache
+ */
+ protected Cache<String, String> getCache(){
+ return cache;
+ }
+
+ @Override
+ public String getIfPresent(@Nonnull final ByteArray keyBytes) {
+ final String key = lookupKeyExtractionStrategy.apply(keyBytes);
+ final String username = cache.getIfPresent(key);
+ log.trace("{} for key '{}'",username != null ? "CacheHit::" : "CacheMiss::", key);
+ return username;
+ }
+
+ @Override
+ public void put(@Nonnull final CredentialRecord registration) {
+ final String key = keyExtractionStrategy.apply(registration);
+ final String value = valueExtractionStrategy.apply(registration);
+ log.trace("CachePut:: key '{}', value '{}'",key, value);
+ cache.put(key,value);
+ }
+
+ @Override
+ public void invalidate(@Nonnull final ByteArray key) {
+ final String lookupKey = lookupKeyExtractionStrategy.apply(key);
+ log.trace("CacheRemove:: key '{}'", lookupKey);
+ cache.invalidate(lookupKey);
+
+ }
+
+ @Override
+ public void invalidateAll() {
+ cache.invalidateAll();
+ }
+
+ /**
+ * Create a new builder.
+ *
+ * @return the builder
+ */
+ public static IUserHandleMappingCacheStage builder() {
+ return new Builder();
+ }
+
+ /** A builder stage.*/
+ public interface IUserHandleMappingCacheStage {
+ /**
+ * The userHandle to username mapping cache.
+ *
+ * @param cache the cache implementation
+ *
+ * @return the next stage
+ */
+ public IBuildStage withCache(@Nonnull final Cache<String, String> cache);
+ }
+
+ /** A builder stage.*/
+ public interface IBuildStage {
+ /**
+ * The strategy to use to convert a {@link CredentialRecord} into a key suitable for the cache.
+ *
+ * @param strategy the strategy to use
+ * @return the next stage
+ */
+ public IBuildStage withKeyExtractionStrategy(final Function<CredentialRecord, String> strategy);
+
+ /**
+ * The strategy to use to convert a ByteArray into a key suitable for the cache.
+ *
+ * @param strategy the strategy to use
+ * @return the next stage
+ */
+ public IBuildStage withLookupKeyExtractionStrategy(
+ final Function<ByteArray, String> strategy);
+
+ /**
+ * The strategy to use to convert a {@link CredentialRecord} into a key suitable for the cache.
+ *
+ * @param strategy the strategy to use
+ * @return the next stage
+ */
+ public IBuildStage withValueExtractionStrategy(
+ final Function<CredentialRecord, String> strategy);
+
+ /**
+ * Build the service.
+ *
+ * @return the instantiated cache service.
+ */
+ public CacheServiceImpl build();
+ }
+
+ /** A builder to safely construct this object.*/
+ public static final class Builder implements IUserHandleMappingCacheStage, IBuildStage {
+ /** The cache.*/
+ @Nullable private Cache<String, String> cacheBuild;
+ /** The key extraction strategy.*/
+ private Function<CredentialRecord, String> keyExtractionStrategyBuild;
+ /** The lookup key extraction strategy.*/
+ private Function<ByteArray, String> lookupKeyExtractionStrategyBuild;
+ /** The value extraction strategy.*/
+ private Function<CredentialRecord, String> valueExtractionStrategyBuild;
+
+ /**
+ * Constructor.
+ */
+ private Builder() {
+ }
+
+ @Override
+ public IBuildStage withCache(@Nonnull final Cache<String, String> cache) {
+ this.cacheBuild = Constraint.isNotNull(cache,
+ "User handle mapping cache can not be null");
+ return this;
+ }
+
+ @Override
+ public IBuildStage withKeyExtractionStrategy(
+ final Function<CredentialRecord, String> keyExtractionStrategy) {
+ this.keyExtractionStrategyBuild = keyExtractionStrategy;
+ return this;
+ }
+
+ @Override
+ public IBuildStage withLookupKeyExtractionStrategy(
+ final Function<ByteArray, String> lookupKeyExtractionStrategy) {
+ this.lookupKeyExtractionStrategyBuild = lookupKeyExtractionStrategy;
+ return this;
+ }
+
+ @Override
+ public IBuildStage withValueExtractionStrategy(
+ final Function<CredentialRecord, String> valueExtractionStrategy) {
+ this.valueExtractionStrategyBuild = valueExtractionStrategy;
+ return this;
+ }
+
+ @Override
+ public CacheServiceImpl build() {
+ return new CacheServiceImpl(this);
+ }
+ }
+
+
+
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializer.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializer.java
index 640c7a9..0c8726e 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializer.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializer.java
@@ -39,7 +39,7 @@ import net.shibboleth.shared.component.AbstractInitializableComponent;
/**
- * Serialize the WebauthnPublicKeyCredentialRecord to a string using Hex encoding.
+ * Serialize the WebauthnPublicKeyCredentialRecord to a string using base64URL encoding.
*/
public class CredentialRegistrationSerializer extends AbstractInitializableComponent
implements StorageSerializer<Set<CredentialRecord>> {
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/DisabledCacheServiceImpl.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/DisabledCacheServiceImpl.java
new file mode 100644
index 0000000..7a2bdf7
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/DisabledCacheServiceImpl.java
@@ -0,0 +1,50 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import javax.annotation.Nonnull;
+
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+
+/**
+ * A no-op implementation of a {@link CacheService}. Constructed if the cache service is effectively disabled.
+ */
+public class DisabledCacheServiceImpl implements CacheService {
+
+
+ @Override
+ public String getIfPresent(@Nonnull final ByteArray userHandle) {
+ return null;
+ }
+
+ @Override
+ public void put(@Nonnull final CredentialRecord registration) {
+ // no-op
+ }
+
+ @Override
+ public void invalidate(@Nonnull final ByteArray userHandle) {
+ //no-op
+
+ }
+
+ @Override
+ public void invalidateAll() {
+ // no-op
+ }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRepository.java
similarity index 62%
rename from webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
rename to webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRepository.java
index 253dac8..34d469a 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRepository.java
@@ -26,6 +26,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
@@ -66,20 +67,24 @@ import net.shibboleth.shared.primitive.LoggerFactory;
* The object to store (value of the storage record) is a set of {@link CredentialRecord registered credentials}.
* Storage records do not expire.</p>
*
- * <p>Note, any exception is wrapped in an unchecked {@link CredentialRepositoryException}. If the caller does not deem
+ * <p>Any exception is wrapped in an unchecked {@link CredentialRepositoryException}. If the caller does not deem
* this terminal, they should catch and handle that error appropriately.</p>
*
+ * <p>Username mappings are stored in two different caches. The first stores userHandle to username mappings, the second
+ * stores credentialId to username mappings. The cache is updated immediately after a lookup or add operation.
+ * Cache entries are removed only when they are found to be invalid on lookup.</p>
+ *
* <p>This repository is thread-safe after it is initialised.</p>
*/
@ThreadSafeAfterInit
-public class IdPStorageServiceCredentialRespository extends AbstractIdentifiableInitializableComponent
+public class IdPStorageServiceCredentialRepository extends AbstractIdentifiableInitializableComponent
implements WebAuthnCredentialRepository {
/** The context to use to partition the storage records.*/
- @Nonnull @NotEmpty private static final String STORAGE_CONTEXT = "net.shibboleth.idp.plugin.authn.webauthn";
+ @Nonnull @NotEmpty protected static final String STORAGE_CONTEXT = "net.shibboleth.idp.plugin.authn.webauthn";
/** Class logger.*/
- @Nonnull private final Logger log = LoggerFactory.getLogger(IdPStorageServiceCredentialRespository.class);
+ @Nonnull private final Logger log = LoggerFactory.getLogger(IdPStorageServiceCredentialRepository.class);
/** Storage record serializer. */
@NonnullAfterInit private StorageSerializer<Set<CredentialRecord>> serializer;
@@ -93,6 +98,83 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
*/
@NonnullAfterInit private ReentrantReadWriteLock lock;
+ /** The service used to manage the userHandle to username mapping cache.*/
+ @NonnullAfterInit private CacheService userHandleMappingCacheService;
+
+ /** The service used to manage the credentialId to username mapping cache.*/
+ @NonnullAfterInit private CacheService credentialIdMappingCacheService;
+
+ /**
+ * A cache loader to be used when this repository is initialised to load mappings into the cache. Does
+ * nothing by default.
+ */
+ @Nonnull private Function<String, List<StorageRecord<Set<CredentialRecord>>>> storageRecordCacheLoader;
+
+ /** Constructor.*/
+ public IdPStorageServiceCredentialRepository() {
+ storageRecordCacheLoader = context -> CollectionSupport.emptyList();
+ }
+
+ /**
+ * Return the read write lock to use.
+ *
+ * @return the lock.
+ */
+ @NonnullAfterInit protected ReentrantReadWriteLock getLock() {
+ return lock;
+ }
+
+ /**
+ * Set the userHandle to username cache service.
+ *
+ * @param cache The userHandleMappingCache to set.
+ */
+ public void setUserHandleMappingCacheService(@Nonnull final CacheService cache) {
+ checkSetterPreconditions();
+ userHandleMappingCacheService = Constraint.isNotNull(cache, "Cache service can not be null");
+ }
+
+ /**
+ * Get the userHandle to username cache service for testing.
+ *
+ * @return the userHandle to username map
+ */
+ @NonnullAfterInit protected CacheService getUserHandleMappingCacheService() {
+ checkComponentActive();
+ return userHandleMappingCacheService;
+ }
+
+ /**
+ * Set the credentialId to username cache service.
+ *
+ * @param cache The userHandleMappingCache to set.
+ */
+ public void setCredentialIdMappingCacheService(@Nonnull final CacheService cache) {
+ checkSetterPreconditions();
+ credentialIdMappingCacheService = Constraint.isNotNull(cache, "Cache service can not be null");
+ }
+
+ /**
+ * Get the credentialId to username cache service for testing.
+ *
+ * @return the userHandle to username map
+ */
+ @NonnullAfterInit protected CacheService getCredentialIdMappingCacheService() {
+ checkComponentActive();
+ return credentialIdMappingCacheService;
+ }
+
+ /**
+ * Set a loader that loads credential records to be cached on initialisation of this storage service.
+ *
+ * @param loader The initialCacheLoader to set.
+ */
+ public void setStorageRecordCacheLoader(
+ @Nonnull final Function<String, List<StorageRecord<Set<CredentialRecord>>>> loader) {
+ checkSetterPreconditions();
+ storageRecordCacheLoader = Constraint.isNotNull(loader, "initialCacheLoader can not be null");
+ }
+
/**
* Set the storage service to store credentials.
*
@@ -129,6 +211,24 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
serializer = Constraint.isNotNull(storageSerializer, "serializer can not be null");
}
+ /**
+ * Get the storage serializer to use.
+ *
+ * @return the serializer.
+ */
+ @NonnullAfterInit protected StorageSerializer<Set<CredentialRecord>> getSerializer() {
+ return serializer;
+ }
+
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doDestroy() {
+ lock = null;
+ super.doDestroy();
+ }
+
+
/** {@inheritDoc} */
@Override
protected void doInitialize() throws ComponentInitializationException {
@@ -139,14 +239,216 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
if (storageService == null) {
throw new ComponentInitializationException("Storage service can not be null");
}
+ if (userHandleMappingCacheService == null) {
+ throw new ComponentInitializationException("UserHandle mapping cache service can not be null");
+ }
+ if (credentialIdMappingCacheService == null) {
+ throw new ComponentInitializationException("CredentialId mapping cache service can not be null");
+ }
lock = new ReentrantReadWriteLock(true);
+ initialCacheLoad();
}
- /** {@inheritDoc} */
- @Override
- protected void doDestroy() {
- lock = null;
- super.doDestroy();
+ /**
+ * Perform initial cache load from the configured cache loader.
+ */
+ private void initialCacheLoad() {
+ try {
+ final var records = storageRecordCacheLoader.apply(STORAGE_CONTEXT);
+ if (records != null) {
+ reloadCache(records);
+ }
+ } catch (final Exception e) {
+ throw new CredentialRepositoryException(e);
+ }
+ }
+
+ /**
+ * Return the username for the given userHandle from the cache.
+ *
+ * <p>Checks if the mapping is still consistent, by looking up the credential by the cached username and
+ * validates it references the same userHandle and still contains the same username.</p>
+ *
+ * <p>If the cache returns {@code null}, either the cache entry does not exist, or the cache is not enabled.
+ * {@code null} does not indicate there is no username for the given userHandle in the storage service.</p>
+ *
+ * @param userHandle the userHandle to find
+ *
+ * @return the username if found in the cache or {@code null} if the username is either not found in the cache
+ * or the mapping is found to be inconsistent.
+ */
+ @Nullable protected String getUsernameFromUserHandleCache(@Nonnull final ByteArray userHandle) {
+ checkComponentActive();
+
+ final String usernameMapping = userHandleMappingCacheService.getIfPresent(userHandle);
+
+ if (usernameMapping != null) {
+ // We now test the mapping is still valid for the record
+ final Set<CredentialRecord> credentials = getRegistrationsByUsername(usernameMapping);
+
+ if (credentials.isEmpty()) {
+ log.trace("{}: UserHandle '{}' to username '{}' mapping is inconsistent, no registrations found,"
+ + " reloading",
+ getId(), userHandle.getBase64Url(), usernameMapping);
+ userHandleMappingCacheService.invalidate(userHandle);
+ } else {
+ // Any of the credentials registered to that user can have the given userHandle, although in practice
+ // they will likely all be the same.
+ final boolean usernameUserHandleMappingIsValid =
+ credentials.stream().anyMatch(cr -> cr.getUserIdentity().getId().compareTo(userHandle)==0);
+ // All usernames contained in the credential set should match that in the map, otherwise the map is
+ // inconsistent
+ final boolean usernameMappingIsValid =
+ credentials.stream().allMatch(cr -> usernameMapping.equals(cr.getUsername()));
+
+ if (!usernameMappingIsValid) {
+ log.trace("{}: UserHandle '{}' to username '{}' mapping is inconsistent, "
+ + "username in mapping does not match the username in the credential, reloading",
+ getId(), userHandle.getBase64Url(), usernameMapping);
+ userHandleMappingCacheService.invalidate(userHandle);
+
+ } else if (!usernameUserHandleMappingIsValid){
+ log.trace("{}: UserHandle '{}' to username '{}' mapping is inconsistent, "
+ + "userHandle is not present in any of '{}''s credentials, reloading",
+ getId(), userHandle.getBase64Url(), usernameMapping, usernameMapping);
+ userHandleMappingCacheService.invalidate(userHandle);
+
+ } else {
+ log.trace("{}: UserHandle '{}' to username '{}' mapping found in cache",
+ getId(), userHandle.getBase64Url(), usernameMapping);
+ return usernameMapping;
+ }
+ }
+ }
+ log.trace("{}: Userhandle '{}' not found in cache", getId(), userHandle.getBase64Url());
+ return null;
+ }
+
+ /**
+ * Return the username for the credentialId from the cache.
+ *
+ * <p>Checks if the mapping is still consistent, by looking up the credential by the cached username and
+ * validates it references the same credentialId and still contains the same username.</p>
+ *
+ * <p>If the cache returns {@code null}, either the cache entry does not exist, or the cache is not enabled.
+ * {@code null} does not indicate there is no username for the given credentialId in the storage service.</p>
+ *
+ * @param credentialId the credentialId to find
+ *
+ * @return the username if found in the cache or {@code null} if the username is either not found in the cache
+ * or the mapping is found to be inconsistent.
+ */
+ @Nullable protected String getUsernameFromCredentialIdCache(@Nonnull final ByteArray credentialId) {
+ checkComponentActive();
+
+ final String usernameMapping = credentialIdMappingCacheService.getIfPresent(credentialId);
+
+ if (usernameMapping != null) {
+ // We now test the mapping is still valid for the record
+ final Set<CredentialRecord> credentials = getRegistrationsByUsername(usernameMapping);
+
+ if (credentials.isEmpty()) {
+ log.trace("{}: CredentialId '{}' to username '{}' mapping is inconsistent, no registrations found,"
+ + " reloading",
+ getId(), credentialId.getBase64Url(), usernameMapping);
+ credentialIdMappingCacheService.invalidate(credentialId);
+ } else {
+ //TODO
+ // Any of the credentials registered to that user can have the given credentialIdMappingCacheService,
+ // although in practice they will likely all be the same.
+ final boolean usernameUserHandleMappingIsValid =
+ credentials.stream().anyMatch(cr ->
+ cr.getCredential().getCredentialId().compareTo(credentialId)==0);
+ // All usernames contained in the credential set should match that in the map, otherwise the map is
+ // inconsistent
+ final boolean usernameMappingIsValid =
+ credentials.stream().allMatch(cr -> usernameMapping.equals(cr.getUsername()));
+
+ if (!usernameMappingIsValid) {
+ log.trace("{}: CredentialId '{}' to username '{}' mapping is inconsistent, "
+ + "username in mapping does not match the username in the credential, reloading",
+ getId(), credentialId.getBase64Url(), usernameMapping);
+ credentialIdMappingCacheService.invalidate(credentialId);
+
+ } else if (!usernameUserHandleMappingIsValid){
+ log.trace("{}: CredentialId '{}' to username '{}' mapping is inconsistent, "
+ + "CredentialId is not present in any of '{}''s credentials, reloading",
+ getId(), credentialId.getBase64Url(), usernameMapping, usernameMapping);
+ credentialIdMappingCacheService.invalidate(credentialId);
+
+ } else {
+ log.trace("{}: CredentialId '{}' to username '{}' mapping found in cache",
+ getId(), credentialId.getBase64Url(), usernameMapping);
+ return usernameMapping;
+ }
+ }
+ }
+ log.trace("{}: CredentialId '{}' not found in cache", getId(), credentialId.getBase64Url());
+ return null;
+ }
+
+ /**
+ * Reload the cache. First, the cache is cleared. Then, all mappings from the set of
+ * credential records are added.
+ *
+ * @param records the credential records to update the cache with.
+ */
+ protected void reloadCache(@Nonnull final List<StorageRecord<Set<CredentialRecord>>> records) {
+ try {
+ log.trace("Reloading '{}' storage records", records.size());
+ // Guava cache is thread-safe here and does not need extra synchronisation
+ userHandleMappingCacheService.invalidateAll();
+
+ if (!records.isEmpty()) {
+ for (final StorageRecord<Set<CredentialRecord>> credStorageRecord : records) {
+
+ final Set<CredentialRecord> credRecords = credStorageRecord.getValue(getSerializer(),
+ STORAGE_CONTEXT, "not-needed");
+ for (final CredentialRecord credential : credRecords) {
+ if (credential != null) {
+ userHandleMappingCacheService.put(credential);
+ credentialIdMappingCacheService.put(credential);
+ }
+ }
+ }
+ }
+ } catch (final IOException e) {
+ throw new CredentialRepositoryException(e);
+ }
+ }
+
+ /**
+ * Update the cache with the userHandle and username mappings found in the credential record set.
+ *
+ * @param updates the credential registrations used to update the cache.
+ */
+ @SuppressWarnings("null")
+ protected void updateUserHandleCache(@Nonnull final Collection<CredentialRecord> updates) {
+ checkComponentActive();
+ updates.stream().filter(Objects::nonNull)
+ .forEach(credential -> userHandleMappingCacheService.put(credential));
+ }
+
+ /**
+ * Update the cache with the credentialId to username mappings found in the credential record set.
+ *
+ * @param updates the credential registrations used to update the cache.
+ */
+ @SuppressWarnings("null")
+ protected void updateCredentialIdCache(@Nonnull final Collection<CredentialRecord> updates) {
+ checkComponentActive();
+ updates.stream().filter(Objects::nonNull)
+ .forEach(credential -> credentialIdMappingCacheService.put(credential));
+ }
+
+ /**
+ * Update the cache with the userHandle and username mappings found in the credential record.
+ *
+ * @param update the credential registration used to update the cache.
+ */
+ protected void updateCache(@Nonnull final CredentialRecord update) {
+ checkComponentActive();
+ userHandleMappingCacheService.put(update);
}
/** {@inheritDoc} */
@@ -191,7 +493,32 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
final Lock readLock = lock.readLock();
try {
readLock.lock();
- return getRegistrationsByUserHandle(userHandle).stream().findAny().map(CredentialRecord::getUsername);
+ assert userHandle != null;
+ //First check the index
+ final String usernameMapping = getUsernameFromUserHandleCache(userHandle);
+ if (usernameMapping != null) {
+ return Optional.of(usernameMapping);
+ }
+
+ // Else we need to find it
+ final Collection<CredentialRecord> credentials = getRegistrationsByUserHandle(userHandle);
+ if (credentials.isEmpty()) {
+ return Optional.empty();
+ }
+ // Check they do not map to more than one username, if they do the repo is inconsistent
+ final List<String> usernames = credentials.stream()
+ .map(CredentialRecord::getUsername)
+ .distinct()
+ .toList();
+
+ if (usernames.size() != 1){
+ throw new CredentialRepositoryException("UserHandle maps to more than one username, credential "
+ + "repository is inconsistent");
+ }
+ // Update the cache
+ updateUserHandleCache(credentials);
+
+ return Optional.of(credentials.iterator().next().getUsername());
} finally {
readLock.unlock();
}
@@ -218,9 +545,9 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
*
* @param userHandle the userHandle to match
*
- * @return registrations that match that userHandle from any user.
+ * @return registrations that match the userHandle from any user.
*/
- private Collection<CredentialRecord> getRegistrationsByUserHandle(final ByteArray userHandle) {
+ protected Collection<CredentialRecord> getRegistrationsByUserHandle(final ByteArray userHandle) {
checkComponentActive();
final Lock readLock = lock.readLock();
try {
@@ -252,26 +579,78 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
final Lock readLock = lock.readLock();
try {
readLock.lock();
- final Set<RegisteredCredential> foundCredentials = new HashSet<>();
+ assert credentialId != null;
+ //First check the index
+ final String usernameMapping = getUsernameFromCredentialIdCache(credentialId);
+ final Set<CredentialRecord> registrations;
+ if (usernameMapping != null) {
+ registrations =
+ getRegistrationsByUsername(usernameMapping)
+ .stream()
+ .filter(reg -> reg.getCredential().getCredentialId().equals(credentialId))
+ .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet())).get();
+ } else {
+ registrations = getRegistrationsByCredentialId(credentialId);
+ }
+ if (registrations.isEmpty()) {
+ return CollectionSupport.emptySet();
+ }
+ // Check they only map to a single username
+ final List<String> usernames = registrations.stream()
+ .map(CredentialRecord::getUsername)
+ .distinct()
+ .toList();
+
+ if (usernames.size() != 1){
+ throw new CredentialRepositoryException("CredentialId maps to more than one username, credential "
+ + "repository is inconsistent");
+ }
+ // Update the cache
+ updateCredentialIdCache(registrations);
+
+ return registrations.stream().map(CredentialRecord::getCredential)
+ .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet())).get();
+
+ } catch (final Exception e) {
+ throw new CredentialRepositoryException(e);
+ } finally {
+ readLock.unlock();
+ }
+ }
+
+ /**
+ * Get registrations from any user by credentialId.
+ *
+ * @param credentialId the credentialId to match
+ *
+ * @return registrations that match the credentialId.
+ */
+ @Nonnull @Unmodifiable protected Set<CredentialRecord> getRegistrationsByCredentialId(
+ final ByteArray credentialId) {
+ checkComponentActive();
+ final Lock readLock = lock.readLock();
+ try {
+ readLock.lock();
+ final Set<CredentialRecord> foundCredentials = new HashSet<>();
for (final Iterator<String> i = storageService.getContextKeys(STORAGE_CONTEXT, null).iterator();
i.hasNext();) {
final String usernameKey = i.next();
assert usernameKey != null;
- final Set<RegisteredCredential> foundCredentialsForUser =
+ final Set<CredentialRecord> foundCredentialsForUser =
getRegistrationsByUsername(usernameKey)
.stream()
.filter(reg -> reg.getCredential().getCredentialId().equals(credentialId))
- .map(CredentialRecord::getCredential)
.collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet())).get();
foundCredentials.addAll(foundCredentialsForUser);
}
- return foundCredentials;
+ return CollectionSupport.copyToSet(foundCredentials);
} catch (final IOException e) {
throw new CredentialRepositoryException(e);
} finally {
readLock.unlock();
- }
+ }
}
+
/** {@inheritDoc} */
@Override
@@ -280,10 +659,14 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
final Lock readLock = lock.readLock();
try {
readLock.lock();
- final StorageRecord<Set<CredentialRecord>> registration =
- storageService.read(STORAGE_CONTEXT, username);
+ final StorageRecord<Set<CredentialRecord>> registration = storageService.read(STORAGE_CONTEXT, username);
if (registration != null) {
- return registration.getValue(serializer, STORAGE_CONTEXT, username);
+ final Set<CredentialRecord> credentials = registration.getValue(serializer, STORAGE_CONTEXT, username);
+ if (!credentials.stream().allMatch(cr -> username.equals(cr.getUsername()))) {
+ throw new CredentialRepositoryException("Not all credentials match username, credential repository"
+ + " is inconsistent");
+ }
+ return credentials;
}
return CollectionSupport.emptySet();
} catch (final IOException e) {
@@ -310,6 +693,10 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
storageService.read(STORAGE_CONTEXT, username);
if (registration != null) {
final Set<CredentialRecord> credentials = registration.getValue(serializer, STORAGE_CONTEXT, username);
+ if (!credentials.stream().allMatch(cr -> username.equals(cr.getUsername()))) {
+ throw new CredentialRepositoryException("Not all credentials match username, credential repository"
+ + " is inconsistent");
+ }
return new VersionedCredentialSet(registration.getVersion(), credentials);
}
return new VersionedCredentialSet(0, CollectionSupport.emptySet());
@@ -357,11 +744,35 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
readLock.unlock();
}
}
+
- /** {@inheritDoc} */
+ /**
+ * {@inheritDoc}
+ * <p>
+ * In addition to the basic functionality, add the new registration to the cache.
+ * </p>
+ */
@Override
- public boolean addRegistrationByUsername(
- @Nonnull final String username, @Nonnull final CredentialRecord reg) {
+ public boolean addRegistrationByUsername(@Nonnull final String username, @Nonnull final CredentialRecord reg) {
+ checkComponentActive();
+ final boolean registrationAdded = addRegistrationByUsernameImpl(username, reg);
+ if (registrationAdded) {
+ userHandleMappingCacheService.put(reg);
+ credentialIdMappingCacheService.put(reg);
+ }
+ return registrationAdded;
+ }
+
+ /**
+ * Add a new credential registration to the user.
+ *
+ * @param username the user to add the registration too
+ * @param registration the credential to add
+ *
+ * @return true if the credential was added, false otherwise
+ */
+ private boolean addRegistrationByUsernameImpl(
+ @Nonnull final String username, @Nonnull final CredentialRecord registration) {
checkComponentActive();
final Lock writeLock = lock.writeLock();
try {
@@ -370,13 +781,13 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
final VersionedCredentialSet existingRegistrations = getRegistrationsByUsernameWithVersion(username);
if (!existingRegistrations.getCredentials().isEmpty()) {
final Set<CredentialRecord> updateSet = new LinkedHashSet<>(existingRegistrations.getCredentials());
- updateSet.add(reg);
+ updateSet.add(registration);
final Long updatedVersion = storageService.updateWithVersion(
existingRegistrations.getVersion(), STORAGE_CONTEXT, username, updateSet, serializer, null);
return updatedVersion != null;
} else {
final Set<CredentialRecord> addSet = new LinkedHashSet<>(1);
- addSet.add(reg);
+ addSet.add(registration);
return storageService.create(STORAGE_CONTEXT, username, addSet, serializer, null);
}
} catch (final IOException | VersionMismatchException e) {
@@ -386,7 +797,6 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
}
}
- /** {@inheritDoc} */
@Override
public boolean removeRegistrationByUsername(
final String username, final CredentialRecord credentialRegistration) {
@@ -419,7 +829,6 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
writeLock.unlock();
}
}
-
/** {@inheritDoc} */
@Override
public int removeRegistrationByCredentialId(final ByteArray credentialId) {
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByAllStrategy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByAllStrategy.java
new file mode 100644
index 0000000..3334084
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByAllStrategy.java
@@ -0,0 +1,63 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.function.BiFunction;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.storage.StorageRecord;
+
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.exception.CredentialRepositoryException;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * A query strategy that loads all known credentials from the datasource based on the storage context label.
+ */
+public class QueryByAllStrategy implements BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>>{
+
+ /** The query accelerator used to lookup credentials.*/
+ @Nonnull private final WebAuthnJDBCReadAllAccelerator queryAccelerator;
+
+ /**
+ * Constructor.
+ *
+ * @param accelerator the JDBC query accelerator to use
+ */
+ public QueryByAllStrategy(@Nonnull final WebAuthnJDBCReadAllAccelerator accelerator) {
+ queryAccelerator = Constraint.isNotNull(accelerator, "Accelerator can not be empty");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public List<StorageRecord<Set<CredentialRecord>>> apply(final String storageContext, final ByteArray credentialId) {
+ if (storageContext == null) {
+ throw new CredentialRepositoryException("Storage context can not be null");
+ }
+ try {
+ return queryAccelerator.readAll(storageContext);
+ } catch (final IOException e) {
+ throw new CredentialRepositoryException(e);
+ }
+ }
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByCredentialIdStrategy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByCredentialIdStrategy.java
new file mode 100644
index 0000000..12e9e13
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByCredentialIdStrategy.java
@@ -0,0 +1,67 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.function.BiFunction;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.storage.StorageRecord;
+
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.exception.CredentialRepositoryException;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * A query strategy that uses the specialised query functionality of the {@link WebAuthnJDBCQueryAccelerator} to
+ * load credentials from the datasource with the given credentialId.
+ */
+public class QueryByCredentialIdStrategy
+ implements BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>>{
+
+ /** The query accelerator used to lookup credentials.*/
+ @Nonnull private final WebAuthnJDBCQueryAccelerator queryAccelerator;
+
+ /**
+ *
+ * Constructor.
+ *
+ * @param accelerator the query accelerator used to lookup credentials.
+ */
+ public QueryByCredentialIdStrategy(@Nonnull final WebAuthnJDBCQueryAccelerator accelerator) {
+ queryAccelerator = Constraint.isNotNull(accelerator, "Accelerator can not be empty");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public List<StorageRecord<Set<CredentialRecord>>> apply(final String storageContext, final ByteArray credentialId) {
+ if (storageContext == null || credentialId == null) {
+ throw new CredentialRepositoryException("Query parameters can not be null");
+ }
+ final String credentialIdB64 = credentialId.getBase64Url();
+ try {
+ return queryAccelerator.queryByCredentialId(storageContext, credentialIdB64);
+ } catch (final IOException e) {
+ throw new CredentialRepositoryException(e);
+ }
+ }
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByUserHandleStrategy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByUserHandleStrategy.java
new file mode 100644
index 0000000..09091ce
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/QueryByUserHandleStrategy.java
@@ -0,0 +1,66 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.function.BiFunction;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.storage.StorageRecord;
+
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.exception.CredentialRepositoryException;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * A query strategy that uses the specialised query functionality of the {@link WebAuthnJDBCQueryAccelerator} to
+ * load credentials from the datasource with the given userHandle.
+ */
+public class QueryByUserHandleStrategy
+ implements BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>>{
+
+ /** The query accelerator used to lookup credentials.*/
+ @Nonnull private final WebAuthnJDBCQueryAccelerator queryAccelerator;
+
+ /**
+ * Constructor.
+ *
+ * @param accelerator the query accelerator used to lookup credentials.
+ */
+ public QueryByUserHandleStrategy(@Nonnull final WebAuthnJDBCQueryAccelerator accelerator) {
+ queryAccelerator = Constraint.isNotNull(accelerator, "Accelerator can not be empty");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public List<StorageRecord<Set<CredentialRecord>>> apply(final String storageContext, final ByteArray userHandle) {
+ if (storageContext == null || userHandle == null) {
+ throw new CredentialRepositoryException("Query parameters can not be null");
+ }
+ final String userHandleB64 = userHandle.getBase64Url();
+ try {
+ return queryAccelerator.queryByUserHandle(storageContext, userHandleB64);
+ } catch (final IOException e) {
+ throw new CredentialRepositoryException(e);
+ }
+ }
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/ReadAllCacheLoadingStrategy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/ReadAllCacheLoadingStrategy.java
new file mode 100644
index 0000000..a9f182f
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/ReadAllCacheLoadingStrategy.java
@@ -0,0 +1,69 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.storage.StorageRecord;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.authn.webauthn.exception.CredentialRepositoryException;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A cache loading strategy that loads all known credentials from the datasource.
+ */
+public class ReadAllCacheLoadingStrategy implements Function<String, List<StorageRecord<Set<CredentialRecord>>>>{
+
+ /** Class logger.*/
+ @Nonnull private final Logger log = LoggerFactory.getLogger(ReadAllCacheLoadingStrategy.class);
+
+ /** The query accelerator used to lookup credentials.*/
+ @Nonnull private final WebAuthnJDBCReadAllAccelerator queryAccelerator;
+
+ /**
+ *
+ * Constructor.
+ *
+ * @param accelerator the JDBC accelerator to use.
+ */
+ public ReadAllCacheLoadingStrategy(@Nonnull final WebAuthnJDBCReadAllAccelerator accelerator) {
+ queryAccelerator = Constraint.isNotNull(accelerator, "Accelerator can not be empty");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public List<StorageRecord<Set<CredentialRecord>>> apply(final String storageContext) {
+ if (storageContext == null) {
+ throw new CredentialRepositoryException("Query parameters can not be null");
+ }
+ try {
+ final List<StorageRecord<Set<CredentialRecord>>> records = queryAccelerator.readAll(storageContext);
+ log.trace("ReadAll cache loading strategy has read '{}' record(s)", records.size());
+ return records;
+ } catch (final IOException e) {
+ throw new CredentialRepositoryException(e);
+ }
+ }
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StorageServiceCredentialRepositoryFactory.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StorageServiceCredentialRepositoryFactory.java
new file mode 100644
index 0000000..233490c
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StorageServiceCredentialRepositoryFactory.java
@@ -0,0 +1,489 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+import org.opensaml.storage.EnumeratableStorageService;
+import org.opensaml.storage.StorageCapabilities;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.StorageSerializer;
+import org.opensaml.storage.StorageService;
+import org.slf4j.Logger;
+import org.springframework.beans.factory.FactoryBean;
+
+import com.google.common.cache.CacheBuilder;
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.WebAuthnCredentialRepository;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A factory bean that decides which credential repository implementation to create based on the options set.
+ * If a {@link WebAuthnJDBCQueryAccelerator} has been injected, the
+ * {@link StrategyBasedIdPStorageServiceCredentialRepository} will be instantiated, else the
+ * {@link IdPStorageServiceCredentialRepository} is instantiated.
+ */
+ at ThreadSafe
+public class StorageServiceCredentialRepositoryFactory extends AbstractInitializableComponent
+ implements FactoryBean<WebAuthnCredentialRepository> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(StorageServiceCredentialRepositoryFactory.class);
+
+ /** The Storage Service used to hold the credential registrations.*/
+ @NonnullAfterInit @GuardedBy("this") private EnumeratableStorageService storageService;
+
+ /** A JDBC based storage service accelerator which improves the performance of specific WebAuthn queries.*/
+ @Nullable @GuardedBy("this") private WebAuthnJDBCAccelerator jdbcAccelerator;
+
+ /** Storage record serializer. */
+ @NonnullAfterInit @GuardedBy("this") private StorageSerializer<Set<CredentialRecord>> serializer;
+
+ /** The duration to wait after last access to expire entries in the cache.*/
+ @Nonnull @GuardedBy("this") private Duration expireAfterAccess;
+
+ /** Should the cache be enabled? Defaults to true. */
+ private boolean enableCache = true;
+
+ /**
+ * A strategy used to lookup credentials by userHandle. Overrides any of the default strategies.
+ **/
+ @Nullable @GuardedBy("this") private BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>>
+ credentialByUserHandleLookupStrategy;
+
+ /**
+ * A strategy used to lookup credentials by credentialId. Overrides any of the default strategies.
+ **/
+ @Nullable @GuardedBy("this") private BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>>
+ credentialByCredentialIdLookupStrategy;
+
+ /** The type of JDBC accelerator.*/
+ public enum AcceleratorType{
+ /** An accelerator that supports specialized queries for credential lookup.*/
+ QUERY,
+ /** An accelerator that supports the efficient reading of all credentials for lookup.*/
+ READALL
+ }
+
+ /**
+ * If the lookup strategies are not used, the jdbcAccelerator has been injected, then the default accelerator type
+ * will be injected into the {@link StrategyBasedIdPStorageServiceCredentialRepository}.
+ */
+ @Nonnull private AcceleratorType defaultAcceleratorType;
+
+ /**
+ * A cache loader to be used when the credential repository is initialized.
+ */
+ @Nullable @GuardedBy("this")
+ private Function<String, List<StorageRecord<Set<CredentialRecord>>>> storageRecordCacheLoader;
+
+ /**
+ * Constructor.
+ */
+ public StorageServiceCredentialRepositoryFactory() {
+ final var duration = Duration.ofMinutes(60);
+ assert duration != null;
+ expireAfterAccess = duration;
+ defaultAcceleratorType = AcceleratorType.READALL;
+ }
+
+
+ @Override protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (storageService == null) {
+ throw new ComponentInitializationException("storageService cannot be null");
+ }
+ if (serializer == null) {
+ throw new ComponentInitializationException("Storage service serializer cannot be null");
+ }
+ }
+
+ /**
+ * Set the default accelerator type to use if the lookup strategies are not set.
+ *
+ * @param type The defaultAcceleratorType to set.
+ */
+ public synchronized void setDefaultAcceleratorType(@Nonnull final AcceleratorType type) {
+ checkSetterPreconditions();
+ defaultAcceleratorType = Constraint.isNotNull(type,
+ "defaultAcceleratorType can not be null");
+ }
+
+ /**
+ *
+ * Get the default accelerator type to use if the lookup strategies are not set.
+ *
+ * @return Returns the defaultAcceleratorType.
+ */
+ @Nonnull public synchronized AcceleratorType getDefaultAcceleratorType() {
+ return defaultAcceleratorType;
+ }
+
+ /**
+ * Set the duration to wait after last access to expire entries from the cache.
+ *
+ * @param expiry The expireAfterAccess to set.
+ */
+ public synchronized void setExpireAfterAccess(@Nonnull final Duration expiry) {
+ checkSetterPreconditions();
+ expireAfterAccess = Constraint.isNotNull(expiry, "ExpireAfterAccess can not be null");
+ Constraint.isFalse(expiry.isNegative() || expiry.isZero(), "ExpireAfterAccess must be greater than 0");
+ }
+
+ /**
+ * Set a JDBC based storage service accelerator which improves the performance of specific WebAuthn queries.
+ *
+ * @param accelerator The jdbcAccelerator to set.
+ */
+ public synchronized void setJdbcAccelerator(@Nullable final WebAuthnJDBCQueryAccelerator accelerator) {
+ checkSetterPreconditions();
+ if (accelerator != null) {
+ jdbcAccelerator = accelerator;
+ }
+ }
+
+ /**
+ * Get the JDBC accelerator to use, if enabled.
+ *
+ * @return the jdbcAccelerator.
+ */
+ @Nullable private synchronized WebAuthnJDBCAccelerator getJdbcAccelerator() {
+ checkComponentActive();
+ return jdbcAccelerator;
+ }
+
+ /**
+ * Set if the cache should be enabled or disabled.
+ *
+ * @param flag should the cache be enabled or disabled.
+ */
+ public synchronized void setEnableCache(final boolean flag) {
+ checkSetterPreconditions();
+ enableCache = flag;
+ }
+
+ /**
+ * Is the cache enabled?
+ *
+ * @return true if the cache is enabled, false otherwise.
+ */
+ private synchronized boolean isEnableCache() {
+ return enableCache;
+ }
+
+ /**
+ * Set the storage service to store credentials.
+ *
+ * @param service the storageService to set.
+ */
+ public synchronized void setStorageService(@Nonnull final StorageService service) {
+ checkSetterPreconditions();
+ Constraint.isNotNull(service, "The Storage Service can not be null");
+ if (service instanceof final EnumeratableStorageService ess) {
+ storageService = ess;
+ } else {
+ throw new ConstraintViolationException("Credential repository requires an WebAuthnJDBCStorageService type");
+ }
+ // Warn against client-side, as keys would not work across browsers.
+ final StorageCapabilities caps = storageService.getCapabilities();
+ if (caps instanceof StorageCapabilities) {
+ if (!caps.isServerSide()) {
+ log.info("Use of client-side storage can make it difficult/impossible to transfer key registrations "
+ + "from one browser to another, which can hinder portability");
+ }
+ if (!caps.isClustered()) {
+ log.info("Use of non-clustered storage service will result in per-node lockout behavior");
+ }
+ }
+ }
+
+ /**
+ * Get the storage service to use.
+ *
+ * @return the storageService.
+ */
+ @NonnullAfterInit public synchronized EnumeratableStorageService getStorageService() {
+ return storageService;
+ }
+
+ /**
+ * Set the storage service serializer to handle {@link CredentialRecord}s.
+ *
+ * @param storageSerializer the serializer to set.
+ */
+ public synchronized void setSerializer(@Nonnull final StorageSerializer<Set<CredentialRecord>> storageSerializer) {
+ checkSetterPreconditions();
+ serializer = Constraint.isNotNull(storageSerializer, "serializer can not be null");
+ }
+
+ /**
+ * Get the storage service serializer to use.
+ *
+ * @return Returns the serializer.
+ */
+ @NonnullAfterInit public synchronized StorageSerializer<Set<CredentialRecord>> getSerializer() {
+ return serializer;
+ }
+
+ /**
+ * Set the JDBC strategy used to lookup credential records based on the userHandle.
+ *
+ * @param strategy The userHandleLookupStrategy to set.
+ */
+ public synchronized void setCredentialByUserHandleLookupStrategy(
+ @Nullable final BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>> strategy) {
+ checkSetterPreconditions();
+ credentialByUserHandleLookupStrategy = strategy;
+ }
+
+ /**
+ * Get the JDBC strategy used to lookup credential records based on the userHandle.
+ *
+ * @return the lookup strategy
+ */
+ private synchronized BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>>
+ getCredentialByUserHandleLookupStrategy() {
+ return credentialByUserHandleLookupStrategy;
+ }
+
+
+ /**
+ * Set the JDBC strategy used to lookup credential records based on the credentialId.
+ *
+ * @param strategy The credentialIdLookupStrategy to set.
+ */
+ public synchronized void setCredentialByCredentialIdLookupStrategy(
+ @Nullable final BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>> strategy) {
+ checkSetterPreconditions();
+ credentialByCredentialIdLookupStrategy = strategy;
+ }
+
+ /**
+ * Get the JDBC strategy used to lookup credential records based on the credentialId.
+ *
+ * @return the lookup strategy
+ */
+ private synchronized BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>>
+ getCredentialByCredentialIdLookupStrategy() {
+ return credentialByCredentialIdLookupStrategy;
+ }
+
+ /**
+ * Set a loader that loads entries to be cached on initialisation of the credential repository.
+ *
+ * @param loader The initialCacheLoader to set.
+ */
+ public synchronized void setStorageRecordCacheLoader(
+ @Nullable final Function<String, List<StorageRecord<Set<CredentialRecord>>>> loader) {
+ checkSetterPreconditions();
+ storageRecordCacheLoader = loader;
+ }
+
+ /**
+ * Get the loader that loads entries to be cached on initialisation of the credential repository.
+ *
+ * @return the user handle mapping initial cache loader.
+ */
+ @Nullable public synchronized Function<String, List<StorageRecord<Set<CredentialRecord>>>>
+ getStorageRecordCacheLoader() {
+ return storageRecordCacheLoader;
+ }
+
+ // CheckStyle: CyclomaticComplexity|MethodLength OFF
+ /** {@inheritDoc} */
+ @SuppressWarnings("null")
+ @Override
+ public WebAuthnCredentialRepository getObject() throws Exception {
+
+ final CacheService userHandleCacheService = isEnableCache()
+ ? CacheServiceImpl.builder()
+ .withCache(CacheBuilder.newBuilder()
+ .expireAfterAccess(expireAfterAccess)
+ .build())
+ .withKeyExtractionStrategy(new UserHandleB64FromCredentialExtractionStrategy())
+ .withLookupKeyExtractionStrategy(new Base64URLKeyExtractionStrategy())
+ .withValueExtractionStrategy(new UsernameFromCredentialExtractionStrategy())
+ .build()
+ : new DisabledCacheServiceImpl();
+
+ final CacheService credentialIdCacheService = isEnableCache()
+ ? CacheServiceImpl.builder()
+ .withCache(CacheBuilder.newBuilder()
+ .expireAfterAccess(expireAfterAccess)
+ .build())
+ .withKeyExtractionStrategy(new CredentialIdB64FromCredentialExtractionStrategy())
+ .withLookupKeyExtractionStrategy(new Base64URLKeyExtractionStrategy())
+ .withValueExtractionStrategy(new UsernameFromCredentialExtractionStrategy())
+ .build()
+ : new DisabledCacheServiceImpl();
+
+ // If custom userHandle and credentialId lookup strategies are defined, we use those
+ if (getCredentialByUserHandleLookupStrategy() != null && getCredentialByCredentialIdLookupStrategy() != null) {
+ log.debug("Constructing custom userHandle and credentialId lookup repository");
+ final StrategyBasedIdPStorageServiceCredentialRepository repo =
+ new StrategyBasedIdPStorageServiceCredentialRepository();
+ repo.setStorageService(getStorageService());
+ repo.setSerializer(getSerializer());
+ repo.setUserHandleMappingCacheService(userHandleCacheService);
+ repo.setCredentialIdMappingCacheService(credentialIdCacheService);
+ repo.setId("StrategyBasedIdPStorageServiceCredentialRepository");
+ repo.setCredentialByUserHandleLookupStrategy(getCredentialByUserHandleLookupStrategy());
+ repo.setCredentialByCredentialIdLookupStrategy(getCredentialByCredentialIdLookupStrategy());
+ if (getStorageRecordCacheLoader() != null) {
+ repo.setStorageRecordCacheLoader(getStorageRecordCacheLoader());
+ }
+ repo.initialize();
+ return repo;
+ }
+
+ // If the JDBc accelerator is defined, we inject specialised query strategies.
+ if (jdbcAccelerator != null) {
+ log.debug("Constructing accelerated JDBC connector");
+ final StrategyBasedIdPStorageServiceCredentialRepository repo =
+ new StrategyBasedIdPStorageServiceCredentialRepository();
+ repo.setStorageService(getStorageService());
+ repo.setSerializer(getSerializer());
+ repo.setUserHandleMappingCacheService(userHandleCacheService);
+ repo.setCredentialIdMappingCacheService(credentialIdCacheService);
+
+ if (defaultAcceleratorType == AcceleratorType.QUERY &&
+ getJdbcAccelerator() instanceof final WebAuthnJDBCQueryAccelerator accelerator) {
+ repo.setCredentialByUserHandleLookupStrategy(new QueryByUserHandleStrategy(accelerator));
+
+ } else if (defaultAcceleratorType == AcceleratorType.READALL &&
+ getJdbcAccelerator() instanceof final WebAuthnJDBCReadAllAccelerator accelerator) {
+ repo.setCredentialByUserHandleLookupStrategy(new QueryByAllStrategy(accelerator));
+ }
+
+ if (defaultAcceleratorType == AcceleratorType.QUERY &&
+ getJdbcAccelerator() instanceof final WebAuthnJDBCQueryAccelerator accelerator) {
+ repo.setCredentialByCredentialIdLookupStrategy(new QueryByCredentialIdStrategy(accelerator));
+
+ } else if (defaultAcceleratorType == AcceleratorType.READALL &&
+ getJdbcAccelerator() instanceof final WebAuthnJDBCReadAllAccelerator accelerator) {
+ repo.setCredentialByCredentialIdLookupStrategy(new QueryByAllStrategy(accelerator));
+ }
+
+
+ if (getStorageRecordCacheLoader() != null) {
+ repo.setStorageRecordCacheLoader(getStorageRecordCacheLoader());
+ } else {
+ if (defaultAcceleratorType == AcceleratorType.READALL &&
+ getJdbcAccelerator() instanceof final WebAuthnJDBCReadAllAccelerator accelerator) {
+ repo.setStorageRecordCacheLoader(new ReadAllCacheLoadingStrategy(accelerator));
+ } else {
+ repo.setStorageRecordCacheLoader(context -> CollectionSupport.emptyList());
+ }
+ }
+
+ repo.setId("StrategyBasedIdPStorageServiceCredentialRepository");
+ repo.initialize();
+ return repo;
+ }
+
+ // Else return the default
+ final IdPStorageServiceCredentialRepository repo = new IdPStorageServiceCredentialRepository();
+ repo.setStorageService(getStorageService());
+ repo.setSerializer(getSerializer());
+ repo.setUserHandleMappingCacheService(userHandleCacheService);
+ repo.setCredentialIdMappingCacheService(credentialIdCacheService);
+ if (getStorageRecordCacheLoader() != null) {
+ repo.setStorageRecordCacheLoader(getStorageRecordCacheLoader());
+ }
+ repo.setId("IdPStorageServiceCredentialRepository");
+ repo.initialize();
+ return repo;
+ }
+ // CheckStyle: CyclomaticComplexity|MethodLength ON
+
+ /** {@inheritDoc} */
+ @Override
+ public Class<?> getObjectType() {
+ return WebAuthnCredentialRepository.class;
+ }
+
+ @Override
+ public boolean isSingleton() {
+ return true;
+ }
+
+
+ /** An extraction strategy that converts the {@link ByteArray} into a base64Url encoded string.*/
+ static class Base64URLKeyExtractionStrategy implements Function<ByteArray, String> {
+
+ /** {@inheritDoc} */
+ @Override
+ public String apply(final ByteArray byteArray) {
+ return byteArray.getBase64Url();
+ }
+ }
+
+ /**
+ * An extraction strategy that converts the {@link CredentialRecord} into a base64Url encoded string of the
+ * userHandle inside the credential.
+ */
+ static class UserHandleB64FromCredentialExtractionStrategy implements Function<CredentialRecord, String> {
+
+ /** {@inheritDoc} */
+ @Override
+ public String apply(final CredentialRecord cred) {
+ return cred.getCredential().getUserHandle().getBase64Url();
+ }
+ }
+
+ /**
+ * An extraction strategy that converts the {@link CredentialRecord} into a base64Url encoded string of the
+ * credentialId inside the credential.
+ */
+ static class CredentialIdB64FromCredentialExtractionStrategy implements Function<CredentialRecord, String> {
+
+ /** {@inheritDoc} */
+ @Override
+ public String apply(final CredentialRecord cred) {
+ return cred.getCredential().getCredentialId().getBase64Url();
+ }
+ }
+
+ /**
+ * An extraction strategy that converts the {@link CredentialRecord} into a username inside the registration.
+ */
+ static class UsernameFromCredentialExtractionStrategy implements Function<CredentialRecord, String> {
+
+ /** {@inheritDoc} */
+ @Override
+ public String apply(final CredentialRecord cred) {
+ return cred.getUsername();
+ }
+ }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StrategyBasedIdPStorageServiceCredentialRepository.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StrategyBasedIdPStorageServiceCredentialRepository.java
new file mode 100644
index 0000000..75bbe02
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StrategyBasedIdPStorageServiceCredentialRepository.java
@@ -0,0 +1,214 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.function.BiFunction;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.storage.StorageRecord;
+import org.slf4j.Logger;
+
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.exception.CredentialRepositoryException;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * An extension of the {@link IdPStorageServiceCredentialRepository} that utalizes specialised
+ * lookup functions to improve the performance of userHandle and credentialId lookup operations.
+ */
+ at ThreadSafeAfterInit
+public class StrategyBasedIdPStorageServiceCredentialRepository extends IdPStorageServiceCredentialRepository {
+
+ /** Class logger.*/
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(StrategyBasedIdPStorageServiceCredentialRepository.class);
+
+ /**
+ * The strategy used to lookup credentials by userHandle. Returned is a List of storage records, where each
+ * storage record can contain a number of credentials for a given user.
+ */
+ @NonnullAfterInit private
+ BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>> credentialByUserHandleLookupStrategy;
+
+ /**
+ * The strategy used to lookup credentials by credentialId. Returned is a List of storage records, where each
+ * storage record can contain a number of credentials for a given user.
+ */
+ @NonnullAfterInit private
+ BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>> credentialByCredentialIdLookupStrategy;
+
+ /**
+ * Package-private Constructor.
+ *
+ * <p>Should only be instantiated by the {@link StorageServiceCredentialRepositoryFactory}.</p>
+ */
+ StrategyBasedIdPStorageServiceCredentialRepository() {
+ }
+
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ if (credentialByCredentialIdLookupStrategy == null) {
+ throw new ComponentInitializationException("Credential lookup by CredentialId lookup "
+ + "strategy can not be null");
+ }
+ if (credentialByUserHandleLookupStrategy == null) {
+ throw new ComponentInitializationException("Credential lookup by UserHandle lookup "
+ + "strategy can not be null");
+ }
+ super.doInitialize();
+ }
+
+
+ /**
+ * Set the strategy used to lookup credential records based on the userHandle.
+ *
+ * @param strategy The userHandleLookupStrategy to set.
+ */
+ public void setCredentialByUserHandleLookupStrategy(
+ @Nonnull final BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>> strategy) {
+ checkSetterPreconditions();
+ credentialByUserHandleLookupStrategy = Constraint.isNotNull(strategy,
+ "userHandleLookupStrategy can not be null");
+ }
+
+
+ /**
+ * Set the strategy used to lookup credential records based on the credentialId.
+ *
+ * @param strategy The credentialIdLookupStrategy to set.
+ */
+ public void setCredentialByCredentialIdLookupStrategy(
+ @Nonnull final BiFunction<String, ByteArray, List<StorageRecord<Set<CredentialRecord>>>> strategy) {
+ checkSetterPreconditions();
+ credentialByCredentialIdLookupStrategy = Constraint.isNotNull(strategy,
+ "credentialIdLookupStrategy can not be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected Collection<CredentialRecord> getRegistrationsByUserHandle(final ByteArray userHandle) {
+ checkComponentActive();
+ final Lock readLock = getLock().readLock();
+ try {
+ readLock.lock();
+ final String userHandleB64 = userHandle.getBase64Url();
+ assert userHandleB64 != null;
+
+ final List<StorageRecord<Set<CredentialRecord>>> records =
+ credentialByUserHandleLookupStrategy.apply(STORAGE_CONTEXT, userHandle);
+ log.trace("{}: Found '{}' unfiltered credential record(s) from the storage service for userHandle '{}'",
+ getId(), records != null ? records.size() : "null", userHandleB64);
+
+ if (records == null) {
+ return CollectionSupport.emptySet();
+ }
+
+ // Flatten out all the records
+ final Set<CredentialRecord> allCredentialRecords = new HashSet<>();
+
+ for (final StorageRecord<Set<CredentialRecord>> storageRecord : records) {
+ final Set<CredentialRecord> individualRecords =
+ storageRecord.getValue(getSerializer(), STORAGE_CONTEXT, userHandleB64);
+ allCredentialRecords.addAll(individualRecords);
+ }
+ // Find the matching credentials, this checks the lookup function found only the correct credentials
+ final Set<CredentialRecord> matchingCredentials = allCredentialRecords.stream()
+ .filter(cred -> userHandle.equals(cred.getUserIdentity().getId()))
+ .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet()))
+ .get();
+
+ log.debug("{}: Found '{}' matching credential(s) for userHandle '{}'", getId(),
+ matchingCredentials.size(), userHandleB64);
+ return matchingCredentials;
+
+
+ } catch (final IOException e) {
+ throw new CredentialRepositoryException(e);
+ } finally {
+ readLock.unlock();
+ }
+
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull public Set<CredentialRecord> getRegistrationsByCredentialId(final ByteArray credentialId) {
+ checkComponentActive();
+ final Lock readLock = getLock().readLock();
+ try {
+ readLock.lock();
+ final String credentialIdB64 = credentialId.getBase64Url();
+ assert credentialIdB64 != null;
+
+ final List<StorageRecord<Set<CredentialRecord>>> records =
+ credentialByCredentialIdLookupStrategy.apply(STORAGE_CONTEXT, credentialId);
+
+ log.trace("{}: Found '{}' unflitered credential record(s) from the storage service for credential '{}'",
+ getId(), records != null ? records.size() : "null", credentialIdB64);
+
+ if (records == null) {
+ return CollectionSupport.emptySet();
+ }
+
+ // Flatten out all the records
+ final Set<CredentialRecord> allCredentialRecords = new HashSet<>();
+ for (final StorageRecord<Set<CredentialRecord>> storageRecord : records) {
+
+ final var serializer = getSerializer();
+ assert serializer != null;
+
+ final Set<CredentialRecord> individualRecords =
+ storageRecord.getValue(serializer, STORAGE_CONTEXT, credentialIdB64);
+ allCredentialRecords.addAll(individualRecords);
+ }
+ // Find matching credentials, this checks the lookup function found only the correct credentials
+ final Set<CredentialRecord> matchingCredentials = allCredentialRecords.stream()
+ .filter(cred -> credentialId.equals(cred.getCredential().getCredentialId()))
+ .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet()))
+ .get();
+
+ log.info("{}: Found '{}' matching credentials for credential '{}'", getId(),
+ matchingCredentials.size(), credentialIdB64);
+ if (!matchingCredentials.isEmpty()) {
+ return matchingCredentials.stream()
+ .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet())).get();
+ }
+
+ return CollectionSupport.emptySet();
+ } catch (final IOException e) {
+ throw new CredentialRepositoryException(e);
+ } finally {
+ readLock.unlock();
+ }
+ }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCAccelerator.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCAccelerator.java
new file mode 100644
index 0000000..956b16b
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCAccelerator.java
@@ -0,0 +1,22 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+/**
+ * A marker interface for a JDBC query accelerator that improves the performance of lookup operations.
+ */
+public interface WebAuthnJDBCAccelerator {
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCAcceleratorImpl.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCAcceleratorImpl.java
new file mode 100644
index 0000000..b31af29
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCAcceleratorImpl.java
@@ -0,0 +1,416 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.sql.DataSource;
+
+import org.opensaml.storage.MutableStorageRecord;
+import org.opensaml.storage.StorageRecord;
+import org.slf4j.Logger;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Positive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * Implementation of a {@link WebAuthnJDBCQueryAccelerator} and {@link WebAuthnJDBCReadAllAccelerator} that uses
+ * specialized queries to lookup credentials by userHandle and credentialId. The default queries are suitable for MySQL
+ * only.
+ */
+public final class WebAuthnJDBCAcceleratorImpl extends AbstractIdentifiableInitializableComponent
+ implements WebAuthnJDBCQueryAccelerator, WebAuthnJDBCReadAllAccelerator {
+
+ /** The SQL to query for all records by userHandle.*/
+ static final String DEFAULT_QUERY_BY_USERHANDLE_RECORD_SQL =
+ "SELECT version, expires, value FROM webauthn.StorageRecords, JSON_TABLE(value,'$[*]' "
+ + "COLUMNS (uid text PATH '$.userIdentity.id')) AS cred WHERE context=? AND cred.uid = ?";
+
+ /** The SQL to query for all records by credentialId.*/
+ static final String DEFAULT_QUERY_BY_CREDENTIALID_RECORD_SQL =
+ "SELECT version, expires, value FROM webauthn.StorageRecords, JSON_TABLE(value,'$[*]' "
+ + "COLUMNS (cid text PATH '$.credential.credentialId')) AS cred WHERE context=? AND cred.cid = ?";
+
+ /** The SQL to get all the records for a specific context.*/
+ static final String DEFAULT_READ_ALL_BY_CONTEXT_SQL =
+ "SELECT version, expires, value FROM StorageRecords WHERE context=?";
+
+ /** Default timeout of SQL queries. */
+ static final Duration DEFAULT_QUERY_TIMEOUT = Duration.ofSeconds(5);
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(WebAuthnJDBCAcceleratorImpl.class);
+
+ /** How many times do we try an operation before giving up? */
+ private int transactionRetries = 3;
+
+ /** If non-null we are doing local locking. */
+ private ReadWriteLock readWriteLock;
+
+ /** Error messages that signal a transaction should be retried. */
+ @Nonnull @NonnullElements private Collection<String> retryableErrors = CollectionSupport.emptyList();
+
+ /** The Data Source. */
+ @NonnullAfterInit private DataSource dataSource;
+
+ /** What transaction isolation do we want? */
+ private int transactionIsolation = Connection.TRANSACTION_SERIALIZABLE;
+
+ /** Timeout of SQL queries. */
+ @Nonnull private final Duration queryTimeout;
+
+ /**
+ * The SQL to query for records by userHandle.
+ * Default: {@value #DEFAULT_QUERY_BY_USERHANDLE_RECORD_SQL}
+ */
+ @Nonnull @NotEmpty private String queryRecordsByUserHandleSQL = DEFAULT_QUERY_BY_USERHANDLE_RECORD_SQL;
+
+ /**
+ * The SQL to query for records by credentialId.
+ * Default: {@value #DEFAULT_QUERY_BY_CREDENTIALID_RECORD_SQL}
+ */
+ @Nonnull @NotEmpty private String queryRecordsByCredentialIdSQL = DEFAULT_QUERY_BY_CREDENTIALID_RECORD_SQL;
+
+ /**
+ * The SQL to get all the records for a specific context.
+ * Default: {@value #DEFAULT_READ_ALL_BY_CONTEXT_SQL}
+ */
+ @Nonnull @NotEmpty private String readAllByContextSQL = DEFAULT_READ_ALL_BY_CONTEXT_SQL;
+
+ /**
+ * Constructor.
+ *
+ * <p>Set the defaults so that they can be over-ridden by Spring.</p>
+ */
+ public WebAuthnJDBCAcceleratorImpl() {
+ assert DEFAULT_QUERY_TIMEOUT != null;
+ queryTimeout = DEFAULT_QUERY_TIMEOUT;
+ }
+
+ /** Will we do thread level locking or delegate to the Database?
+ * @param what do we want to lock locally?
+ */
+ public void setLocalLocking(final boolean what) {
+ if (what) {
+ readWriteLock = new ReentrantReadWriteLock(true);
+ } else {
+ readWriteLock = null;
+ }
+ }
+
+ /**
+ * What errors do we retry?
+ *
+ * @param errors what to set.
+ */
+ public void setRetryableErrors(@Nonnull @NonnullElements final List<String> errors) {
+ retryableErrors = CollectionSupport.copyToList(Constraint.isNotNull(errors, "errors must not be null"));
+ Constraint.noNullItems(errors, "errors must not have null members");
+ }
+
+ /** set {@link #transactionRetries}.
+ * @param count how many time to try before we bail.
+ */
+ public void setTransactionRetries(@Positive final int count) {
+ transactionRetries = count;
+ if (count < 0) {
+ throw new ConstraintViolationException("transaction retry must be positive");
+ }
+ }
+
+ /**
+ * Get the number of transaction retries.
+ *
+ * @return the transaction retries before we bail.
+ */
+ protected int getTransactionRetries() {
+ return transactionRetries;
+ }
+
+ /** Set the parameter that will be passed to {@link Connection#setTransactionIsolation(int)}.
+ * @param what the value to set
+ */
+ public void setTransactionIsolation(final int what) {
+ transactionIsolation = what;
+ }
+
+
+ /**
+ * Set the SQL needed to query for credential registrations by their userHandle.
+ *
+ * @param what The queryRecordsByUserHandleSQL to set.
+ */
+ public void setQueryRecordsByUserHandleSQL(final String what) {
+ queryRecordsByUserHandleSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+ "ReadRecordsByUserHandleSQL should be non-null and non empty");
+ }
+
+ /**
+ * Set the SQL needed to query for credential registrations by their credentialId.
+ *
+ * @param what The queryRecordsByCredentialIdSQL to set.
+ */
+ public void setQueryRecordsByCredentialIdSQL(final String what) {
+ queryRecordsByCredentialIdSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+ "ReadRecordsByCredentialIdSQL should be non-null and non empty");
+ }
+
+ /** SQL to read all contexts.
+ * @param what the SQL to set.
+ */
+ public void setReadAllByContextSQL(@Nonnull @NotEmpty final String what) {
+ readAllByContextSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+ "Read All By Context SQL should be non-null and non empty");
+ }
+
+ /**
+ * Set the {@link DataSource}.
+ *
+ * @param source the datasource to set.
+ */
+ public void setDataSource(@Nonnull final DataSource source) {
+ dataSource = Constraint.isNotNull(source, "DataSource should be non null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Unmodifiable @NonnullElements @NotLive public <T> List<StorageRecord<Set<T>>> queryByUserHandle(
+ final String context, final String userHandle) throws IOException {
+ return CollectionSupport.copyToList(query(queryRecordsByUserHandleSQL, new String[] {context, userHandle}));
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Unmodifiable @NonnullElements @NotLive public <T> List<StorageRecord<Set<T>>> queryByCredentialId(
+ final String context, final String credentialId) throws IOException {
+ return CollectionSupport.copyToList(query(queryRecordsByCredentialIdSQL, new String[] {context, credentialId}));
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull @NonnullElements @NotLive public <T> List<StorageRecord<T>>
+ readAll(@Nonnull @NotEmpty final String context) throws IOException {
+ return CollectionSupport.copyToList(query(readAllByContextSQL, new String[] {context}));
+ }
+
+ /**
+ * Execute the given SQL over the established connection and return the results. It is required that all SQL
+ * statements return the version, expires, and value columns, in that order.
+ *
+ * @param <T> the storage record type
+ * @param sql the SQL to execute
+ * @param parameters the parameters to bind to the SQL statement
+ *
+ * @return the results of executing the given query,
+ *
+ * @throws IOException on error
+ *
+ */
+ // CheckStyle: CyclomaticComplexity OFF
+ @Nonnull
+ private <T> List<StorageRecord<T>> query(@Nonnull @NotEmpty final String sql,
+ @Nullable @NonnullElements final String[] parameters) throws IOException {
+
+ Constraint.isNotEmpty(Constraint.isNotNull(sql, "query: sql must not be null"),
+ "read: context must not be empty");
+
+ int retries = transactionRetries;
+ while (true) {
+ try (final ConnectionWithLock connection = new ConnectionWithLock(true, false);
+ final PreparedStatement stmnt = connection.prepareStatement(sql)) {
+
+ log.trace("Read:: '{}' parameters: '{}'", sql, parameters);
+ if (parameters != null) {
+ for (int i=0; i< parameters.length; i++) {
+ stmnt.setString(i+1, parameters[i]);
+ }
+ }
+
+ final List<StorageRecord<T>> results = new ArrayList<>();
+ try (final ResultSet resultSet = stmnt.executeQuery()) {
+ while (resultSet.next()) {
+ final Long returnedVersion = resultSet.getLong(1);
+ final Long returnedExpires = getExpires(resultSet, 2);
+ final String returnedValue = Constraint.isNotNull(resultSet.getString(3),
+ "value field must not be null");
+ if (returnedExpires != null && System.currentTimeMillis() >= returnedExpires) {
+ log.debug("Record '{}' expired at '{}', omitting", returnedValue, returnedExpires);
+ } else {
+ final MutableStorageRecord<T> result = new WebAuthnJDBCStorageRecord<>(returnedValue,
+ returnedExpires, returnedVersion);
+ results.add(result);
+ }
+ }
+ }
+ return results;
+ } catch (final SQLException e) {
+ boolean retry = false;
+ for (final String msg : retryableErrors) {
+ if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+ log.warn("Caught retryable SQL exception", e);
+ retry = true;
+ break;
+ }
+ }
+
+ if (retry) {
+ if (--retries < 0) {
+ log.warn("Error retryable, but retry limit exceeded");
+ throw new IOException(e);
+ }
+ log.info("Retrying JDBC Read operation");
+ } else {
+ throw new IOException(e);
+ }
+ }
+ }
+ }
+ // CheckStyle: CyclomaticComplexity ON
+ /**
+ * Return the value of expires in the supplied column of the supplied {@link ResultSet}.
+ *
+ * @param results the results whose current row we want to inspect
+ * @param columm the column
+ * @return the expiration (converting an SQL null into a null)
+ * @throws SQLException if the results interrogation fails
+ */
+ @Nullable protected static Long getExpires(@Nonnull final ResultSet results, final int columm) throws SQLException {
+ final long value = results.getLong(columm);
+ if (results.wasNull()) {
+ return null;
+ }
+ return value;
+ }
+
+
+ /** A Class to encapsulate a {@link Connection} protected by an optional
+ * read/write lock.
+ * Because the class implements {@link AutoCloseable} the unlock can "just happen"
+ */
+ protected class ConnectionWithLock implements AutoCloseable {
+
+ /** The connection we set up. */
+ @Nonnull private final Connection connection;
+
+ /** The lock we may or may not have set up. */
+ @Nullable private final Lock threadLock;
+
+ /** Was this created autocommit? */
+ private final boolean isAutoCommit;
+
+ /** Has {@link #commit()} been called? */
+ private boolean isCommited;
+
+ /** Has {@link #rollback()} been called? */
+ private boolean isRolledBack;
+
+ /** Constructor.
+ * @param autoCommit What to set {@link Connection#setAutoCommit(boolean)} to
+ * @param writeLock Whether to grab an write lock on the table (if we are locking)
+ * @throws SQLException if any of the SQL operations throw one
+ */
+ public ConnectionWithLock(final boolean autoCommit, final boolean writeLock) throws SQLException {
+ final Connection con = dataSource.getConnection();
+ assert con != null;
+ connection = con;
+ isAutoCommit = autoCommit;
+ connection.setAutoCommit(autoCommit);
+ if (transactionIsolation > Connection.TRANSACTION_NONE) {
+ connection.setTransactionIsolation(transactionIsolation);
+ }
+ if (readWriteLock != null) {
+ if (writeLock) {
+ threadLock = readWriteLock.writeLock();
+ } else {
+ threadLock = readWriteLock.readLock();
+ }
+ assert threadLock != null;
+ threadLock.lock();
+ } else {
+ threadLock = null;
+ }
+ }
+
+ /** Delegated operation to the encapsulated {@link Connection}.
+ * @param sql what to prepare
+ * @return what the encapsulated {@link Connection} returns
+ * @throws SQLException if encapsulated {@link Connection} does
+ */
+ public PreparedStatement prepareStatement(final String sql) throws SQLException {
+ final PreparedStatement statement = connection.prepareStatement(sql);
+ statement.setQueryTimeout((int) queryTimeout.toSeconds());
+ return statement;
+ }
+
+ /** Delegated operation to the encapsulated {@link Connection}.
+ * @throws SQLException if encapsulated {@link Connection} does
+ */
+ public void commit() throws SQLException {
+ assert !isAutoCommit && !isCommited && !isRolledBack;
+ isCommited = true;
+ connection.commit();
+ }
+
+ /** Delegated operation to the encapsulated {@link Connection}.
+ * @throws SQLException if encapsulated {@link Connection} does
+ */
+ public void rollback() throws SQLException {
+ assert !isAutoCommit && !isCommited && !isRolledBack;
+ isRolledBack = true;
+ connection.rollback();
+ }
+
+
+ @Override
+ public void close() {
+ assert isAutoCommit || isCommited || isRolledBack;
+ try {
+ connection.close();
+ } catch (final SQLException e) {
+ log.error("Auto close failed", e);
+ }
+ if (threadLock != null) {
+ threadLock.unlock();
+ }
+ }
+ }
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCQueryAccelerator.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCQueryAccelerator.java
new file mode 100644
index 0000000..f03caf4
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCQueryAccelerator.java
@@ -0,0 +1,71 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.storage.StorageRecord;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+
+/**
+ * An accelerator for JDBC queries to improve the performance of lookup operations that search for non-indexed fields,
+ * namely; userHandle and credentialId.
+ */
+public interface WebAuthnJDBCQueryAccelerator extends WebAuthnJDBCAccelerator {
+
+ /**
+ * Queries the underlying data source for credentials associated with the given userHandle.
+ * <p>
+ * Implementations may retrieve data from a JSON field (e.g., a "value" column), requiring
+ * JSON querying capabilities in the underlying RDBMS.</p>
+ *
+ * @param <T> the storage record type
+ * @param context the context
+ * @param userHandle the userHandle to query for
+ *
+ * @return a list of credentials that are a match for the given userHandle
+ *
+ * @throws IOException on error
+ */
+ @Nonnull @NotLive @Unmodifiable <T> List<StorageRecord<Set<T>>> queryByUserHandle(
+ @Nonnull @NotEmpty final String context,
+ @Nonnull @NotEmpty final String userHandle) throws IOException;
+
+ /**
+ * Queries the underlying data source for credentials associated with the given credentialId.
+ * <p>
+ * Implementations may retrieve data from a JSON field (e.g., a "value" column), requiring
+ * JSON querying capabilities in the underlying RDBMS.</p>
+ *
+ * @param <T> the storage record type
+ * @param context the context
+ * @param credentialId the credentialId to query for
+ *
+ * @return a list of credentials that are a match for the given userHandle
+ *
+ * @throws IOException on error
+ */
+ @Nonnull @NotLive @Unmodifiable <T> List<StorageRecord<Set<T>>> queryByCredentialId(
+ @Nonnull @NotEmpty final String context,
+ @Nonnull @NotEmpty final String credentialId) throws IOException;
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCReadAllAccelerator.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCReadAllAccelerator.java
new file mode 100644
index 0000000..ddec83e
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCReadAllAccelerator.java
@@ -0,0 +1,45 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import java.io.IOException;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.storage.StorageRecord;
+
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * An accelerator for JDBC queries to improve the performance of lookup operations.
+ */
+public interface WebAuthnJDBCReadAllAccelerator extends WebAuthnJDBCAccelerator{
+
+ /**
+ * Read all credential records in a single query from a given context.
+ *
+ * @param <T> the storage record record type
+ * @param context a storage context label
+ *
+ * @return all found credential records.
+ *
+ * @throws IOException on error
+ */
+ @Nonnull @NonnullElements <T> List<StorageRecord<T>> readAll(@Nonnull @NotEmpty String context)
+ throws IOException;
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCStorageRecord.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCStorageRecord.java
new file mode 100644
index 0000000..7cbad79
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/WebAuthnJDBCStorageRecord.java
@@ -0,0 +1,51 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.storage.MutableStorageRecord;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/** Storage record used by {@link WebAuthnJDBCAcceleratorImpl}.
+ * This is notable in that it allows creation with a specified version (from
+ * the database).
+ * @param <T> type of record
+ */
+class WebAuthnJDBCStorageRecord<T> extends MutableStorageRecord<T> {
+
+ /** Length of the context column. */
+ public static final int CONTEXT_SIZE = 255;
+
+ /** Length of the key column. */
+ public static final int KEY_SIZE = 255;
+
+ /**
+ * Constructor.
+ *
+ * @param val The value to store
+ * @param exp The expiration
+ * @param version The version.
+ */
+ public WebAuthnJDBCStorageRecord(@Nonnull @NotEmpty final String val,
+ @Nullable final Long exp, @Nullable final Long version) {
+ super(val, exp);
+ if (version != null) {
+ setVersion(version);
+ }
+ }
+}
diff --git a/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 51e638d..3fe77f2 100644
--- a/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -93,15 +93,26 @@
p:preferredPublickeyParams="%{idp.authn.webauthn.preferredPublicKeyParams:EdDSA,ES256,ES384,ES512,RS1,RS256,RS384,RS512}"
p:credentialRepository="#{getObject('shibboleth.authn.WebAuthn.CredentialRepository') ?: getObject('shibboleth.authn.WebAuthn.DefaultCredentialRepository')}"
p:fidoMetadataService="#{'false'.equals('%{idp.authn.webauthn.metadata.enabled:false}') ? null : getObject('shibboleth.authn.WebAuthn.WebAuthnFidoMetadataServiceFactory') ?: getObject('shibboleth.authn.WebAuthn.DefaultWebAuthnFidoMetadataServiceFactory')}"/>
-
-
+
<bean id="shibboleth.authn.WebAuthn.DefaultCredentialRepository" scope="singleton"
- class="net.shibboleth.idp.plugin.authn.webauthn.storage.impl.IdPStorageServiceCredentialRespository"
+ class="net.shibboleth.idp.plugin.authn.webauthn.storage.impl.StorageServiceCredentialRepositoryFactory"
p:storageService-ref="#{'%{idp.authn.webauthn.StorageService:shibboleth.StorageService}'.trim()}"
- p:serializer-ref="shibboleth.authn.WebAuthn.DefaultCredentialRepositoryStorageSerializer"/>
+ p:defaultAcceleratorType="%{idp.authn.webauthn.StorageService.jdbcAccelerator.defaultType:READALL}"
+ p:credentialByUserHandleLookupStrategy="#{getObject('%{idp.authn.webauthn.StorageService.jdbcAccelerator.credentialByUserHandleLookupStrategy:}')}"
+ p:credentialByCredentialIdLookupStrategy="#{getObject('%{idp.authn.webauthn.StorageService.jdbcAccelerator.credentialByCredentialIdLookupStrategy:}')}"
+ p:jdbcAccelerator="#{getObject('%{idp.authn.webauthn.StorageService.jdbcAccelerator:WebAuthnJDBCAccelerator}')}"
+ p:serializer="#{getObject('shibboleth.authn.WebAuthn.CredentialRepositoryStorageSerializer') ?: getObject('shibboleth.authn.WebAuthn.DefaultCredentialRepositoryStorageSerializer')}"
+ p:expireAfterAccess="%{idp.authn.webauthn.StorageService.cache.expireAfterAccess:PT60M}"
+ p:enableCache="%{idp.authn.webauthn.StorageService.cache.enable:true}">
+ </bean>
+
+
+ <bean id="shibboleth.authn.WebAuthn.DefaultCredentialRepositoryStorageSerializer"
+ class="net.shibboleth.idp.plugin.authn.webauthn.storage.impl.CredentialRegistrationSerializer" />
- <bean id="shibboleth.authn.WebAuthn.DefaultCredentialRepositoryStorageSerializer"
- class="net.shibboleth.idp.plugin.authn.webauthn.storage.impl.CredentialRegistrationSerializer"/>
+ <!-- Parent bean for the JDBC query accelerator used to speed up queries to specific database instances -->
+ <bean id="shibboleth.authn.WebAuthn.JDBCAccelerator" abstract="true"
+ class="net.shibboleth.idp.plugin.authn.webauthn.storage.impl.WebAuthnJDBCAcceleratorImpl"/>
<!-- The optional FIDO Metadata service. Lazy-init, only constructed if used. -->
<bean id="shibboleth.authn.WebAuthn.DefaultWebAuthnFidoMetadataServiceFactory"
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
index 57dbfda..c436cd7 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
@@ -30,11 +30,18 @@ idp.authn.webauthn.supportedPrincipals = \
# The storage service to use as a credential repository
#idp.authn.webauthn.StorageService = shibboleth.StorageService
-
-# Which type of flow is supported? Usernameless or passwordless
+# Enable or disable the credential repository cache
+#idp.authn.webauthn.StorageService.cache.enable = true
+# Set the expires after last access time on the cache
+#idp.authn.webauthn.StorageService.cache.expireAfterAccess = PT60M
+# The JDBC credential repository accelerator to use, only to be enabled if you are using a JDBC storage service with a compatible RDMS datasource
+#idp.authn.webauthn.StorageService.jdbcAccelerator = WebAuthnJDBCAccelerator
+#idp.authn.webauthn.StorageService.jdbcAccelerator.defaultType = READALL
+
+# Which type of flow is supported? Usernameless (true) or passwordless (false)
#idp.authn.webauthn.usernameless.enabled = false
-# Enable this flow to act as a second factor.
+# Allow this flow to act as a second factor.
#idp.authn.webauthn.2fa.enabled = false
# Which previous factors are acceptable to allow the WebAuthn flow to act as a second factor of authentication e.g. authn/Password.
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/plugin.properties b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/plugin.properties
index d490d7b..1a5bbe7 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/plugin.properties
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/plugin.properties
@@ -2,7 +2,7 @@
plugin.id = net.shibboleth.idp.plugin.authn.webauthn
# Only used when package manifest is not available
-plugin.version = 1.2.0
+plugin.version = 1.2.1
plugin.license =/net/shibboleth/idp/plugin/authn/webauthn/doc/licence.txt
# No prereqs
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
index f36c055..935b68d 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
@@ -36,7 +36,7 @@ import javax.security.auth.Subject;
import org.mockito.Mockito;
import org.opensaml.profile.context.ProfileRequestContext;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.ApplicationContext;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.webflow.context.ExternalContext;
@@ -52,6 +52,7 @@ import org.springframework.webflow.executor.FlowExecutionResult;
import org.springframework.webflow.executor.FlowExecutorImpl;
import org.springframework.webflow.test.MockExternalContext;
import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
@@ -82,7 +83,7 @@ import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationCo
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
import net.shibboleth.idp.plugin.authn.webauthn.principal.WebAuthnUserIdPrinicpal;
import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
-import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.IdPStorageServiceCredentialRespository;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.IdPStorageServiceCredentialRepository;
import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.InMemoryRegistrationStorage;
import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.MockAuthenticator;
import net.shibboleth.idp.session.IdPSession;
@@ -122,15 +123,16 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
/** The CBOR friendly json mapper.*/
protected ObjectMapper jsonMapper;
+ @Autowired
+ protected ApplicationContext context;
+
/** A mock authenticator to use for creating Authenticator Attestations and Assertions etc.*/
protected MockAuthenticator mockAuthenticator;
/** The relying party.*/
- protected RelyingParty rp;
+ protected RelyingParty rp;
- @Autowired
- @Qualifier("shibboleth.authn.WebAuthn.DefaultCredentialRepository")
- protected IdPStorageServiceCredentialRespository credentialRepo;
+ protected IdPStorageServiceCredentialRepository credentialRepo;
protected AbstractWebAuthnFlowTest(final String id) {
this(id, END_STATE_ID);
@@ -139,6 +141,17 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
protected AbstractWebAuthnFlowTest(final String id, final String endId) {
flowId = id;
endStateId = endId == null ? END_STATE_ID : endId;
+
+ }
+
+ @BeforeClass
+ public void init() {
+ try {
+ credentialRepo = context.getBean("shibboleth.authn.WebAuthn.DefaultCredentialRepository",
+ IdPStorageServiceCredentialRepository.class);
+ } catch (final Exception e) {
+ fail(e.getMessage());
+ }
}
/**
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestRegistrationFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestRegistrationFlow.java
index 8479448..94c7a5e 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestRegistrationFlow.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestRegistrationFlow.java
@@ -125,9 +125,9 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
- // Register another uses credential and try and delete it
+ // Register another users credential and try and delete it
final CredentialRecord registrationAnotherUser =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ createCredentialRegistration("another-user", DISPLAY_NAME, "YW5vdGhlci11c2VyLWhhbmRsZQ==", null);
credentialRepo.addRegistrationByUsername("another-user", registrationAnotherUser);
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
@@ -145,7 +145,7 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
result.getSecond().resume(externalContext);
assertCurrentStateEquals("DisplayWebAuthnRegistrationView", result.getSecond());
- // Test is has been removed
+ // Test is has not been removed
assertEquals(credentialRepo.getCredentialIdsForUsername(USERNAME).size(),1);
assertEquals(credentialRepo.getCredentialIdsForUsername("another-user").size(),1);
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java
index c34287b..14f01b3 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java
@@ -172,7 +172,7 @@ public class TestSecondFactorFlow extends AbstractWebAuthnFlowTest{
final CredentialRecord differentUserRegistration =
createCredentialRegistration("different-user", DISPLAY_NAME, "d2R3ZXFmZndmd2U=", null);
- credentialRepo.addRegistrationByUsername("different-user", registration);
+ credentialRepo.addRegistrationByUsername("different-user", differentUserRegistration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
buildMfaContext(prc.ensureSubcontext(AuthenticationContext.class), "authn/Password");
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
index d085c63..bc0b7a6 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
@@ -375,6 +375,42 @@ public abstract class AbstractWebAuthnTest {
return reg;
}
+
+ protected CredentialRecord createRegistration(
+ final String name, final String displayName, final byte[] userHandle) throws Exception {
+
+ final Map<String, String> clientDataCreate = createClientData("webauthn.create", ORIGIN, CHALLENGE_B64);
+
+ final var user = UserIdentity.builder()
+ .name(name)
+ .displayName(displayName)
+ .id(new ByteArray(userHandle))
+ .build();
+
+ // Need to register a new credential first
+ final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
+ mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
+ userHandle, null);
+
+ final RegisteredCredential credential = RegisteredCredential.builder()
+ .credentialId(attestation.getId())
+ .userHandle(new ByteArray(userHandle))
+ .publicKeyCose(attestation.getResponse().getParsedAuthenticatorData()
+ .getAttestedCredentialData().get().getCredentialPublicKey())
+ .build();
+
+ return CredentialRecord.builder()
+ .withUserIdentity(user)
+ .withUsername(name)
+ .withTransports(new TreeSet<AuthenticatorTransport>())
+ .withRegistrationTime(Instant.now())
+ .withCredential(credential)
+ .withCredentialNickname("nickname")
+ .withDiscoverable(Optional.of(Boolean.TRUE))
+ .withUserVerified(true)
+ .build();
+
+ }
/**
* Create a credential registration with a new attestation response from the mock authenticator. Use a default
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
index 684c176..21ef17f 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
@@ -17,14 +17,13 @@ package net.shibboleth.idp.plugin.authn.webauthn.storage.impl;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
-import java.util.Map;
import java.util.Optional;
-import java.util.TreeSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
@@ -34,29 +33,27 @@ import org.opensaml.storage.impl.MemoryStorageService;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
-import com.yubico.webauthn.RegisteredCredential;
-import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
-import com.yubico.webauthn.data.AuthenticatorTransport;
+import com.google.common.cache.CacheBuilder;
import com.yubico.webauthn.data.ByteArray;
-import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
-import com.yubico.webauthn.data.PublicKeyCredential;
-import com.yubico.webauthn.data.UserIdentity;
+import net.shibboleth.idp.plugin.authn.webauthn.exception.CredentialRepositoryException;
import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnTest;
import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.StorageServiceCredentialRepositoryFactory.Base64URLKeyExtractionStrategy;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.StorageServiceCredentialRepositoryFactory.CredentialIdB64FromCredentialExtractionStrategy;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.StorageServiceCredentialRepositoryFactory.UserHandleB64FromCredentialExtractionStrategy;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.StorageServiceCredentialRepositoryFactory.UsernameFromCredentialExtractionStrategy;
/**
- * Tests for {@link IdPStorageServiceCredentialRespository}.
+ * Tests for {@link IdPStorageServiceCredentialRepository}.
*/
public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthnTest {
- private UserIdentity user;
-
private StorageService storageService;
- private IdPStorageServiceCredentialRespository repo;
-
+ private IdPStorageServiceCredentialRepository repo;
+ @SuppressWarnings("null")
@Override
@BeforeMethod
public void setup() throws Exception {
@@ -67,50 +64,31 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
((MemoryStorageService)storageService).initialize();
final var storageSerializer = new CredentialRegistrationSerializer();
storageSerializer.initialize();
- repo = new IdPStorageServiceCredentialRespository();
+ repo = new IdPStorageServiceCredentialRepository();
repo.setId("test-repo");
repo.setSerializer(storageSerializer);
- repo.setStorageService(storageService);
+ repo.setStorageService(storageService);
+ final var userHandleCacheService = CacheServiceImpl.builder().withCache(CacheBuilder.newBuilder()
+ .maximumSize(1000)
+ .build())
+ .withKeyExtractionStrategy(new UserHandleB64FromCredentialExtractionStrategy())
+ .withLookupKeyExtractionStrategy(new Base64URLKeyExtractionStrategy())
+ .withValueExtractionStrategy(new UsernameFromCredentialExtractionStrategy())
+ .build();
+ repo.setUserHandleMappingCacheService(userHandleCacheService);
+ final var creedentialIdCacheService = CacheServiceImpl.builder().withCache(CacheBuilder.newBuilder()
+ .maximumSize(1000)
+ .build())
+ .withKeyExtractionStrategy(new CredentialIdB64FromCredentialExtractionStrategy())
+ .withLookupKeyExtractionStrategy(new Base64URLKeyExtractionStrategy())
+ .withValueExtractionStrategy(new UsernameFromCredentialExtractionStrategy())
+ .build();
+ repo.setCredentialIdMappingCacheService(creedentialIdCacheService);
repo.initialize();
mockAuthenticator = new MockAuthenticator(RPID);
}
- private CredentialRecord createRegistration(
- final String name, final String displayName, final byte[] userHandle) throws Exception {
-
- final Map<String, String> clientDataCreate = createClientData("webauthn.create", ORIGIN, CHALLENGE_B64);
-
- user = UserIdentity.builder()
- .name(name)
- .displayName(displayName)
- .id(new ByteArray(userHandle))
- .build();
-
- // Need to register a new credential first
- final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
- mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- userHandle, null);
-
- final RegisteredCredential credential = RegisteredCredential.builder()
- .credentialId(attestation.getId())
- .userHandle(new ByteArray(userHandle))
- .publicKeyCose(attestation.getResponse().getParsedAuthenticatorData()
- .getAttestedCredentialData().get().getCredentialPublicKey())
- .build();
-
- return CredentialRecord.builder()
- .withUserIdentity(user)
- .withUsername(name)
- .withTransports(new TreeSet<AuthenticatorTransport>())
- .withRegistrationTime(Instant.now())
- .withCredential(credential)
- .withCredentialNickname("nickname")
- .withDiscoverable(Optional.of(Boolean.TRUE))
- .withUserVerified(true)
- .build();
-
- }
-
+ @SuppressWarnings("null")
@Test
public void testAddRegistrationByUsername() throws Exception {
@@ -265,7 +243,7 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
assertEquals(registrations.size(), 1);
}
- @Test
+ @Test(expectedExceptions = CredentialRepositoryException.class)
public void testRemoveRegistrationByCredentialId_TwoRegistrationsSameCredential() throws Exception {
final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
@@ -273,35 +251,19 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
// Both share the same registration, should not happen in practice
repo.addRegistrationByUsername("pdoe", registration);
- var registrations = repo.getRegistrationsByUsername("jdoe");
- assertNotNull(registrations);
- assertEquals(registrations.size(), 1);
- var iterator = registrations.iterator();
- var credReg = iterator.next();
- assertEquals(credReg.getUsername(),"jdoe");
- assertEquals(credReg.getCredential().getCredentialId(),
- registration.getCredential().getCredentialId());
- registrations = repo.getRegistrationsByUsername("pdoe");
+
+ var registrations = repo.getRegistrationsByUsername("jdoe");
assertNotNull(registrations);
assertEquals(registrations.size(), 1);
- iterator = registrations.iterator();
- credReg = iterator.next();
+ final var iterator = registrations.iterator();
+ final var credReg = iterator.next();
assertEquals(credReg.getUsername(),"jdoe");
assertEquals(credReg.getCredential().getCredentialId(),
registration.getCredential().getCredentialId());
- final int removalCount = repo.removeRegistrationByCredentialId(registration.getCredential().getCredentialId());
-
- assertEquals(removalCount, 2);
-
- registrations = repo.getRegistrationsByUsername("jdoe");
- assertNotNull(registrations);
- assertEquals(registrations.size(), 0);
-
+ //Should fail because the 'pdoe' has registrations which do not match that username
registrations = repo.getRegistrationsByUsername("pdoe");
- assertNotNull(registrations);
- assertEquals(registrations.size(), 0);
}
@Test
@@ -610,6 +572,78 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
}
+ @Test
+ public void testGetUsernameFromCredentialIdCache() throws Exception {
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ final String username = repo.getUsernameFromCredentialIdCache(registration.getCredential().getCredentialId());
+ assertEquals(username, "jdoe");
+ }
+
+ @Test
+ public void testGetUsernameFromCredentialIdCache_NotInCache() throws Exception {
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+ repo.getCredentialIdMappingCacheService().invalidate(registration.getCredential().getCredentialId());
+ final String username = repo.getUsernameFromCredentialIdCache(registration.getCredential().getCredentialId());
+ assertEquals(username, null);
+ }
+
+ @Test
+ public void testGetUsernameFromCredentialIdCache_CacheEntryInconsistent() throws Exception {
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ repo.getCredentialIdMappingCacheService().invalidate(registration.getCredential().getCredentialId());
+ // Add a new mapping with the same credential but different username
+ repo.getCredentialIdMappingCacheService().put(registration.toBuilder()
+ .withUsername("not-jdoe")
+ .withTransports(registration.getTransports())
+ .withRegistrationTime(registration.getRegistrationTime())
+ .withCredential(registration.getCredential())
+ .build());
+
+ final String username = repo.getUsernameFromCredentialIdCache(registration.getCredential().getCredentialId());
+ assertEquals(username, null);
+ }
+
+ @Test
+ public void testGetUsernameFromUserHandlCache() throws Exception {
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ final String username = repo.getUsernameFromUserHandleCache(registration.getUserIdentity().getId());
+ assertEquals(username, "jdoe");
+ }
+
+ @Test
+ public void testGetUsernameFromUserHandlCache_NotInCache() throws Exception {
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+ repo.getUserHandleMappingCacheService().invalidate(registration.getUserIdentity().getId());
+ final String username = repo.getUsernameFromUserHandleCache(registration.getUserIdentity().getId());
+ assertEquals(username, null);
+ }
+
+ @Test
+ public void testGetUsernameFromUserHandlCache_CacheEntryInconsistent() throws Exception {
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ repo.getUserHandleMappingCacheService().invalidate(registration.getUserIdentity().getId());
+ // Add a new mapping with the same credential but different username
+ repo.getUserHandleMappingCacheService().put(registration.toBuilder()
+ .withUsername("not-jdoe")
+ .withTransports(registration.getTransports())
+ .withRegistrationTime(registration.getRegistrationTime())
+ .withCredential(registration.getCredential())
+ .build());
+
+ final String username = repo.getUsernameFromUserHandleCache(registration.getUserIdentity().getId());
+ assertEquals(username, null);
+ }
+
@Test
public void testGetRegistrationByUsernameAndCredentialId_NoneFound() throws Exception {
@@ -649,7 +683,7 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
// Add one for pdoe
final CredentialRecord registrationthree =
- createRegistration("pdoe", "John Doe", "user-handle-pdoe".getBytes());
+ createRegistration("pdoe", "Paul Doe", "user-handle-pdoe".getBytes());
repo.addRegistrationByUsername("pdoe", registrationthree);
final var registrationsForFirst = repo.lookupAll(registration.getCredential().getCredentialId());
@@ -662,6 +696,15 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
}
+ @Test
+ public void testlookupAll_NoRegistrations() throws Exception {
+ final var registrations = repo.lookupAll(new ByteArray("bytes".getBytes()));
+ assertNotNull(registrations);
+ assertEquals(registrations.size(),0);
+ }
+
+ //TODO lookup all with cache
+
@Test
public void testLookup() throws Exception {
@@ -697,6 +740,15 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
}
+ /*
+ * Test for: userHandle found
+ * <ol>
+ * <li>choose username</li>
+ * <li>lookup record</li>
+ * <li>at least one credential matches userId</li>
+ * <li>username is OK</li>
+ * </ol>
+ */
@Test
public void testGetUsernameForUserHandle() throws Exception {
@@ -709,9 +761,48 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
// Add one for pdoe
final CredentialRecord registrationthree =
- createRegistration("pdoe", "John Doe", "user-handle-pdoe".getBytes());
+ createRegistration("pdoe", "John Doe", "pdoe-user-handle".getBytes());
repo.addRegistrationByUsername("pdoe", registrationthree);
+
+ final var usernameJdoe = repo.getUsernameForUserHandle(registration.getUserIdentity().getId());
+ assertTrue(usernameJdoe.isPresent());
+ assertEquals(usernameJdoe.get(), "jdoe");
+
+ final var usernamePdoe = repo.getUsernameForUserHandle(registrationthree.getUserIdentity().getId());
+ assertTrue(usernamePdoe.isPresent());
+ assertEquals(usernamePdoe.get(), "pdoe");
+
+ // Check nothing comes back for an unrecognised userhandle
+ final var usernameNotfound= repo.getUsernameForUserHandle(new ByteArray("not-found".getBytes()));
+ assertFalse(usernameNotfound.isPresent());
+
+ }
+
+ @Test
+ public void testGetUsernameForUserHandle_NoCache() throws Exception {
+
+ repo = new IdPStorageServiceCredentialRepository();
+ repo.setId("test-repo");
+ final var storageSerializer = new CredentialRegistrationSerializer();
+ storageSerializer.initialize();
+ repo.setSerializer(storageSerializer);
+ repo.setStorageService(storageService);
+ repo.setUserHandleMappingCacheService(new DisabledCacheServiceImpl());
+ repo.setCredentialIdMappingCacheService(new DisabledCacheServiceImpl());
+ repo.initialize();
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ // Now add another for jdoe
+ final CredentialRecord registrationTwo = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registrationTwo);
+
+ // Add one for pdoe
+ final CredentialRecord registrationthree =
+ createRegistration("pdoe", "John Doe", "pdoe-user-handle".getBytes());
+ repo.addRegistrationByUsername("pdoe", registrationthree);
+
final var usernameJdoe = repo.getUsernameForUserHandle(registration.getUserIdentity().getId());
assertTrue(usernameJdoe.isPresent());
assertEquals(usernameJdoe.get(), "jdoe");
@@ -726,6 +817,390 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
}
+ /* Test what happens if more than one credential record is found with different usernames.*/
+ @Test(expectedExceptions = CredentialRepositoryException.class)
+ public void testGetUsernameForUserHandle_NoCache_InconsistentNumberOfUsernames() throws Exception {
+
+ repo = new IdPStorageServiceCredentialRepository();
+ repo.setId("test-repo");
+ final var storageSerializer = new CredentialRegistrationSerializer();
+ storageSerializer.initialize();
+ repo.setSerializer(storageSerializer);
+ repo.setStorageService(storageService);
+ repo.setUserHandleMappingCacheService(new DisabledCacheServiceImpl());
+ repo.setCredentialIdMappingCacheService(new DisabledCacheServiceImpl());
+ repo.initialize();
+
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ // Now add another for jdoe with a different username
+ final CredentialRecord registrationTwo =
+ createRegistration("wrong-username", "John Doe", "user-handle".getBytes());
+ repo.addRegistrationByUsername("jdoe", registrationTwo);
+
+ //Should error
+ repo.getUsernameForUserHandle(registration.getUserIdentity().getId());
+
+ }
+
+ /**
+ * Should throw an execption as the username key is not consistent with the username in the registration.
+ *
+ * @throws Exception on error
+ */
+ @Test(expectedExceptions = CredentialRepositoryException.class)
+ public void testGetUsernameByUserHandle_EnsureUsernameFromCredentialUsed_UsernameInCredentialIsDifferent()
+ throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe-actual", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ final Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+
+ }
+
+ /* As the index key and credential usernames are inconsistent, this should throw an exception.*/
+ @Test(expectedExceptions = CredentialRepositoryException.class)
+ public void testGetUsernameByUserHandle_EnsureUsernameFromMapMatchesCredentials()
+ throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe-actual", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ // Now change the userHandleMap to reference the wrong username
+ repo.getUserHandleMappingCacheService().invalidateAll();
+ repo.getUserHandleMappingCacheService().put(createRegistration("jdoe", "John Doe", userHandleBytes.getBytes()));
+
+ final Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+
+ }
+
+ @Test
+ public void testGetUsernameByUserHandle_AddOne_GetUsername_RemoveCredential()
+ throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ // User should exist
+ Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ // Remove credential
+ repo.removeRegistrationByUsername(username.get(), registration);
+
+ // Should not be found
+ username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isEmpty());
+
+ }
+ @Test
+ public void testGetUsernameByUserHandle_AddTwo_GetUsername_RemoveCredential()
+ throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ final CredentialRecord registrationTwo = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+ repo.addRegistrationByUsername("jdoe", registrationTwo);
+
+ // User should exist
+ Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ // Remove credential
+ repo.removeRegistrationByUsername(username.get(), registration);
+
+ // Should be found still
+ username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ }
+
+ @Test
+ public void testGetUsernameByUserHandle_AddTwo_GetUsername_RemoveCredential_AddNew()
+ throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ final CredentialRecord registrationTwo = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+ repo.addRegistrationByUsername("jdoe", registrationTwo);
+
+ // User should exist
+ Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ // Remove credential
+ repo.removeRegistrationByUsername(username.get(), registration);
+
+ // Should be found still
+ username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ // Add another to jdoe
+ final CredentialRecord registrationThree= createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registrationThree);
+
+ username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ }
+
+ /* If one of the credentials does not agree on username, the repo is not consistent.*/
+ @Test(expectedExceptions = CredentialRepositoryException.class)
+ public void testGetUsernameByUserHandle_UsernamesAreNotConsistent() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ final CredentialRecord registrationTwo = createRegistration("not-correct", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+ repo.addRegistrationByUsername("jdoe", registrationTwo);
+
+ final Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ }
+
+ @Test
+ public void testGetUsernameByUserHandle_SameCredentialOnTwoRecords() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ final CredentialRecord registrationTwo = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+ repo.addRegistrationByUsername("jdoe", registrationTwo);
+
+ // Now add the same second registration for a different storage record (key)
+ repo.addRegistrationByUsername("another-jdoe", registrationTwo);
+
+ final Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+ }
+
+ @Test
+ public void testGetUsernameByUserHandle_SameUser_TwoRecordsDifferentUserID() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final ByteArray userHandleSecondBytes = new ByteArray("different-user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ final CredentialRecord registrationTwo = createRegistration("jdoe", "John Doe", userHandleSecondBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+ repo.addRegistrationByUsername("jdoe", registrationTwo);;
+
+ Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ username = repo.getUsernameForUserHandle(userHandleSecondBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+ }
+ @Test
+ public void testGetUsernameByUserHandle_MappingIsInvalid() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ // Change the mapping so the wrong userHandle points to the correct username
+ final ByteArray differentUserHandleBytes = new ByteArray("different-user-handle".getBytes());
+ repo.getUserHandleMappingCacheService().invalidateAll();
+ repo.getUserHandleMappingCacheService().
+ put(createRegistration("jdoe", "John Doe", differentUserHandleBytes.getBytes()));
+
+ // Should ignore the cache as inconsistent and try to find it, which it wont
+ final Optional<String> username = repo.getUsernameForUserHandle(differentUserHandleBytes);
+ assertTrue(username.isEmpty());
+ }
+
+ @Test
+ public void testGetUsernameByUserHandle_CacheFirst_Then_RemoveEntry_UpdateCache() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ assert(registration != null);
+ repo.addRegistrationByUsername("jdoe", registration);
+
+ // Should find and cache the userHandle to username
+ Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ assertEquals(((CacheServiceImpl)repo.getUserHandleMappingCacheService())
+ .getCache().size(), 1);
+
+ // Should find from cache
+ username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isPresent());
+ assertEquals(username.get(), "jdoe");
+
+ // Remove the registration
+ repo.removeRegistrationByUsername("jdoe", registration);
+
+ // Cache should be invalid and not found
+ username = repo.getUsernameForUserHandle(userHandleBytes);
+ assertTrue(username.isEmpty());
+
+ // Test not in cache
+ assertNull(repo.getUserHandleMappingCacheService().getIfPresent(userHandleBytes));
+
+ assertEquals(((CacheServiceImpl)repo.getUserHandleMappingCacheService())
+ .getCache().size(), 0);
+ }
+
+
+ /* Should result in two mappings in the index.*/
+// @SuppressWarnings("null")
+// @Test
+// public void testGetUsernameForUserHandle_DifferentUserHandleForSameUSer() throws Exception {
+//
+// final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+// repo.addRegistrationByUsername("jdoe", registration);
+//
+// // Now add another for jdoe with different user handle
+// final CredentialRecord registrationTwo = createRegistration("jdoe", "John Doe", "different-user-handle".getBytes());
+// repo.addRegistrationByUsername("jdoe", registrationTwo);
+//
+// // And the index should have two mappings
+// final var mappingOne = repo.getStorageService().read(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// registration.getUserIdentity().getId().getBase64Url());
+// assertEquals(mappingOne.getValue(), "jdoe");
+//
+// final var mappingTwo = repo.getStorageService().read(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// registrationTwo.getUserIdentity().getId().getBase64Url());
+// assertEquals(mappingOne.getValue(), "jdoe");
+//
+//
+// final var usernameJdoe = repo.getUsernameForUserHandle(registration.getUserIdentity().getId());
+// assertTrue(usernameJdoe.isPresent());
+// assertEquals(usernameJdoe.get(), "jdoe");
+//
+//
+// final var usernameJdoeTwo = repo.getUsernameForUserHandle(registrationTwo.getUserIdentity().getId());
+// assertTrue(usernameJdoeTwo.isPresent());
+// assertEquals(usernameJdoeTwo.get(), "jdoe");
+//
+//
+// }
+
+ /*
+ * Tests for:
+ * userHandle found (does not belong to that username)
+ * <ol>
+ * <li>choose username</li>
+ * <li>lookup record</li>
+ * <li>no credentials have the same userHandle</li>
+ * <li>username FAIL</li>
+ * <li>remove mapping</li>
+ * <li>lookup ALL records</li>
+ * <li>find username from ANY of the userHandle matches</li>
+ * </ol>
+ */
+// @SuppressWarnings("null")
+// @Test
+// public void testGetUsernameForUserHandle_MappingPointsToDifferetCredential() throws Exception {
+//
+// final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+// repo.addRegistrationByUsername("jdoe", registration);
+//
+// // Now add another for bob
+// final CredentialRecord registrationTwo = createRegistration("bob", "Bob Doe", "bob-user-handle".getBytes());
+// repo.addRegistrationByUsername("bob", registrationTwo);
+//
+// // Remove any mappings that have been created
+// repo.getStorageService().delete(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// registration.getUserIdentity().getId().getBase64Url());
+// repo.getStorageService().delete(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// registrationTwo.getUserIdentity().getId().getBase64Url());
+//
+// // Now change the userHandle in the record so the mapping points to bob which is a different userHandle
+// repo.getStorageService().create(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// registration.getUserIdentity().getId().getBase64Url(), "bob", null);
+//
+// // This should find the mapping is not consistent and revert to the brute force behaviour
+// final var usernameJoe= repo.getUsernameForUserHandle(registration.getUserIdentity().getId());
+// assertTrue(usernameJoe.isPresent());
+// assertEquals(usernameJoe.get(), "jdoe");
+//
+// }
+
+ /*
+ * Tests: userHandle not found (SLOW)
+ * <ol>
+ * <li>lookup ALL records</li>
+ * <li>find username from ANY of the userHandle matches</li>
+ * <li>add mapping</li>
+ * <li>username is OK</li>
+ * </ol>
+ */
+// @SuppressWarnings("null")
+// @Test
+// public void testGetUsernameForUserHandle_NoMapping() throws Exception {
+//
+// final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+// repo.addRegistrationByUsername("jdoe", registration);
+//
+// // Now delete the mapping
+// repo.getStorageService().delete(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// registration.getUserIdentity().getId().getBase64Url());
+//
+// // Should find it from brute force
+// var usernameJdoe = repo.getUsernameForUserHandle(registration.getUserIdentity().getId());
+// assertTrue(usernameJdoe.isPresent());
+// assertEquals(usernameJdoe.get(), "jdoe");
+//
+// //And it should restore the mapping
+// final var mapping = repo.getStorageService().read(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// registration.getUserIdentity().getId().getBase64Url());
+// assertEquals(mapping.getValue(), "jdoe");
+//
+// //This one should come from the index (although we do not explicitly test for that)
+// usernameJdoe = repo.getUsernameForUserHandle(registration.getUserIdentity().getId());
+// assertTrue(usernameJdoe.isPresent());
+// assertEquals(usernameJdoe.get(), "jdoe");
+//
+// }
+
+ /*
+ * Tests for: userHandle found (userHandle no longer exists in credentials)
+ * <ol>
+ * <li>choose username</li>
+ * <li>lookup record</li>
+ * <li>no credentials have the same userHandle</li>
+ * <li>username FAIL</li>
+ * <li>remove mapping</li>
+ * <li>lookup ALL records</li>
+ * <li>do not find username in ANY registrations</li>
+ * <li>fail</li>
+ * </ol>
+ */
+// @Test
+// public void testGetUsernameForUserHandle_NoMapping_NoCredentials() throws Exception {
+// final ByteArray userHandle = new ByteArray("no-credential-match".getBytes());
+// // Add a valid credential but one that does not match for the userHandle supplied
+// final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+// repo.addRegistrationByUsername("jdoe", registration);
+//
+// // Create a mapping for the userHandle we will search for, that points jdoe which does not have the same handle
+// repo.getStorageService().create(IdPStorageServiceCredentialRepository.STORAGE_CONTEXT_USERHANDLE_INDEX,
+// userHandle.getBase64Url(), "jdoe", null);
+//
+// // Should not find it
+// final var usernameJdoe = repo.getUsernameForUserHandle(userHandle);
+// assertFalse(usernameJdoe.isPresent());
+// }
+
+ @Test(expectedExceptions = CredentialRepositoryException.class)
+ public void testGetUsernameByUserHandle_EnsureUsernameFromCredentialUsed() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ // Use a different key here, so there is a mismatch, but the username from the registration
+ // should be authorative
+ repo.addRegistrationByUsername("not-jdoe", registration);
+
+ final Optional<String> username = repo.getUsernameForUserHandle(userHandleBytes);
+ }
+
/* Failure here would be non-deterministic if it happened.*/
@Test
public final void testThreadSafetyAdd() throws Exception {
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StrategyBasedIdPStorageServiceCredentialRepositoryTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StrategyBasedIdPStorageServiceCredentialRepositoryTest.java
new file mode 100644
index 0000000..7e573b6
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/StrategyBasedIdPStorageServiceCredentialRepositoryTest.java
@@ -0,0 +1,201 @@
+/*
+ * 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.idp.plugin.authn.webauthn.storage.impl;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.fail;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+
+import org.mockito.Mockito;
+import org.opensaml.storage.EnumeratableStorageService;
+import org.opensaml.storage.StorageRecord;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.yubico.webauthn.data.ByteArray;
+
+import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnTest;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeTest;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Tests for {@link StrategyBasedIdPStorageServiceCredentialRepository}.
+ */
+public class StrategyBasedIdPStorageServiceCredentialRepositoryTest extends AbstractWebAuthnTest {
+
+
+ /** The repository to test.*/
+ @NonnullBeforeTest private StrategyBasedIdPStorageServiceCredentialRepository repo;
+
+ @NonnullBeforeTest private WebAuthnJDBCQueryAccelerator queryAccelerator;
+
+ @NonnullBeforeTest private EnumeratableStorageService storageService;
+
+ @NonnullBeforeTest private CredentialRegistrationSerializer serializer;
+
+ @NonnullBeforeTest private CacheService userHandleCacheService;
+
+ @NonnullBeforeTest private CacheService credentialIdCacheService;
+
+
+ @BeforeMethod public void setUp() throws ComponentInitializationException {
+ try {
+ storageService = Mockito.mock(EnumeratableStorageService.class);
+ // This could be mocked, but is useful to maintain correct behaviour here.
+ serializer = new CredentialRegistrationSerializer();
+ serializer.initialize();
+ final var storageSerializer = new CredentialRegistrationSerializer();
+ storageSerializer.initialize();
+ repo = new StrategyBasedIdPStorageServiceCredentialRepository();
+ repo.setId("test-repo");
+ repo.setSerializer(storageSerializer);
+ repo.setStorageService(storageService);
+ userHandleCacheService = Mockito.mock(CacheService.class);
+ credentialIdCacheService = Mockito.mock(CacheService.class);
+ repo.setUserHandleMappingCacheService(userHandleCacheService);
+ repo.setCredentialIdMappingCacheService(credentialIdCacheService);
+ repo.setCredentialByCredentialIdLookupStrategy((ctx, credId) -> Collections.emptyList());
+ repo.setCredentialByUserHandleLookupStrategy((ctx, userHandle) -> Collections.emptyList());
+ mockAuthenticator = new MockAuthenticator(RPID);
+ } catch (final Exception e) {
+ throw new ComponentInitializationException(e);
+ }
+ }
+
+ @Override
+ @BeforeMethod
+ public void setup() throws Exception {
+ super.setup();
+ }
+
+ @Test
+ public void testGetRegistrationsByUserHandle() throws Exception {
+
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+
+ repo.setCredentialByUserHandleLookupStrategy((ctx, userHandle) ->
+ {
+ try {
+ return CollectionSupport.listOf(new WebAuthnJDBCStorageRecord<Set<CredentialRecord>>(
+ serializer.serialize(CollectionSupport.setOf(registration)),0l, 1l));
+ } catch (final IOException e) {
+ fail(e.getMessage());
+ return null;
+ }
+ });
+ repo.initialize();
+
+ final Collection<CredentialRecord> credentials = repo.getRegistrationsByUserHandle(userHandleBytes);
+ assertEquals(credentials.size(), 1);
+ assertEquals(credentials.iterator().next().getUsername(), "jdoe");
+ }
+
+ @Test
+ public void testGetRegistrationsByUserHandle_NoResults() throws Exception {
+
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+
+ repo.setCredentialByUserHandleLookupStrategy((ctx, userHandle) -> CollectionSupport.emptyList());
+ repo.initialize();
+
+ final Collection<CredentialRecord> credentials = repo.getRegistrationsByUserHandle(userHandleBytes);
+ assertEquals(credentials.size(), 0);
+ }
+
+ /* Mock what happens if the result from the lookup strategy is a table of duplicated rows. This can happen if you
+ * use the JSON_TABLE type syntax in MySQL.
+ */
+ @Test
+ public void testGetRegistrationsByUserHandle_LookupProducesDuplicatedRows() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+
+ repo.setCredentialByUserHandleLookupStrategy((ctx, userHandle) ->
+ {
+ try {
+ return CollectionSupport.listOf(
+ new StorageRecord(serializer.serialize(CollectionSupport.setOf(registration)),null),
+ new StorageRecord(serializer.serialize(CollectionSupport.setOf(registration)),null));
+ } catch (final IOException e) {
+ fail(e.getMessage());
+ return null;
+ }
+ });
+ repo.initialize();
+
+ final Collection<CredentialRecord> credentials = repo.getRegistrationsByUserHandle(userHandleBytes);
+ assertEquals(credentials.size(), 1);
+ assertEquals(credentials.iterator().next().getUsername(), "jdoe");
+
+ }
+
+ @Test
+ public void testGetRegistrationsByUserHandle_MoreThanOneCredential() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+ final CredentialRecord registrationTwo = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+
+ repo.setCredentialByUserHandleLookupStrategy((ctx, userHandle) ->
+ {
+ try {
+ return CollectionSupport.listOf(
+ new StorageRecord(serializer.serialize(CollectionSupport.setOf(registration)),null),
+ new StorageRecord(serializer.serialize(CollectionSupport.setOf(registrationTwo)),null));
+ } catch (final IOException e) {
+ fail(e.getMessage());
+ return null;
+ }
+ });
+ repo.initialize();
+
+ final Collection<CredentialRecord> credentials = repo.getRegistrationsByUserHandle(userHandleBytes);
+ assertEquals(credentials.size(), 2);
+ assertEquals(credentials.iterator().next().getUsername(), "jdoe");
+ }
+
+ @Test
+ public void testGetRegistrationsByCredentialId() throws Exception {
+ final ByteArray userHandleBytes = new ByteArray("user-handle".getBytes());
+ final CredentialRecord registration = createRegistration("jdoe", "John Doe", userHandleBytes.getBytes());
+
+ repo.setCredentialByCredentialIdLookupStrategy((ctx, credId) ->
+ {
+ try {
+ return CollectionSupport.listOf(
+ new StorageRecord(serializer.serialize(CollectionSupport.setOf(registration)),null));
+ } catch (final IOException e) {
+ fail(e.getMessage());
+ return null;
+ }
+ });
+ repo.initialize();
+
+ final Collection<CredentialRecord> credentials = repo.getRegistrationsByCredentialId(
+ registration.getCredential().getCredentialId());
+ assertEquals(credentials.size(), 1);
+ assertEquals(credentials.iterator().next().getUsername(), "jdoe");
+
+
+ }
+
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list