[java-idp-plugin-webauthn] branch main updated: JWEBAUTHN-44 - Document approach for conditionally requiring 2fa for the registration flow

Phil Smart philip.smart at jisc.ac.uk
Fri Feb 28 16:33:50 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=414a208857912e7cc01df375413cadbc4f1c8aa2

The following commit(s) were added to refs/heads/main by this push:
     new 414a208  JWEBAUTHN-44 - Document approach for conditionally requiring 2fa for the registration flow
414a208 is described below

commit 414a208857912e7cc01df375413cadbc4f1c8aa2
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Feb 28 16:33:48 2025 +0000

    JWEBAUTHN-44 - Document approach for conditionally requiring 2fa for the
    registration flow
    
     - Add a new access control predicate to guard against access to the
    registration page if you have WebAuthn credentials but have not
    performed a 'fresh' and 'strong' authentication.
     - Needs to be enabled and the MFA flow configured suitably for it to be
    used
    
    https://shibboleth.atlassian.net/browse/JWEBAUTHN-44
---
 .../webauthn/context/WebAuthnGuardContext.java     |  66 ++++++++
 .../admin/impl/MultiPredicateAccessPredicate.java  |  65 ++++++++
 .../RequireStrongFreshAuthnAccessPredicate.java    | 166 +++++++++++++++++++++
 .../META-INF/net.shibboleth.idp/postconfig.xml     |   5 +-
 ...RequireStrongFreshAuthnAccessPredicateTest.java |  92 ++++++++++++
 .../authn/webauthn/impl/AbstractWebAuthnTest.java  |  27 +++-
 6 files changed, 414 insertions(+), 7 deletions(-)

diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnGuardContext.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnGuardContext.java
new file mode 100644
index 0000000..044d7c5
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnGuardContext.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.context;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.messaging.context.BaseContext;
+
+/**
+ * A context to hold signals about the type of authentication performed. For example, was authn sufficient for 
+ * accessing the WebAuthn credential registration flow. 
+ * 
+ * <p>Use of this context is optional and determined by the deployer.</p> 
+ *
+ * @since 1.1.0 
+ */
+public class WebAuthnGuardContext extends BaseContext {
+    
+    /** 
+     * A flag that can be set to indicate the user just (fresh) performed some kind of 'strong' authentication. This 
+     * can be used in conjunction with an access control policy to determine if the user should be granted access to the 
+     * registration page if other conditions are met e.g. if the user has FIDO2 credentials and the flag is not set, 
+     * deny access.
+     * 
+     * <p>This is not intended to replace AuthenticationContextClasses, but can be used in certain cases where the
+     * flow might legally change behaviour from one authentication to the next e.g. allow password login for the first
+     * registration, but require something stronger thereafter (and use this as a flag to indicate that).</p>
+     */
+    private boolean stronglyAuthenticated;
+    
+    
+    
+    /** 
+     * Set a flag to indicate the user performed some kind of 'strong' authentication. 
+     * 
+     * @param flag The flag to set.
+     */
+    @Nonnull public WebAuthnGuardContext setStronglyAuthenticated(final boolean flag) {
+        stronglyAuthenticated = flag;
+        return this;
+    }
+    
+    /**
+     * Did the user performed some kind of 'strong' authentication? (as determined by the flow)
+     * 
+     * @return true iff the user performed some kind of strong authentication, false otherwise.
+     */
+    public boolean isStronglyAuthenticated() {
+        return stronglyAuthenticated;
+    }
+
+    
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/MultiPredicateAccessPredicate.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/MultiPredicateAccessPredicate.java
new file mode 100644
index 0000000..8351d83
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/MultiPredicateAccessPredicate.java
@@ -0,0 +1,65 @@
+/*
+ * 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.admin.impl;
+
+import java.util.List;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.NotLive;
+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;
+
+/**
+ * Access control implementation based on a {@link List} of predicates over a {@link ProfileRequestContext}. Iteration
+ * over the predicates terminates when one returns 'false'. If no predicate returns 'false', access is allowed. 
+ * 
+ * @since 1.1.0
+ */
+public class MultiPredicateAccessPredicate extends AbstractIdentifiableInitializableComponent  
+        implements Predicate<ProfileRequestContext>{
+    
+    /** List of predicates to evaluate.*/
+    @Nonnull @NotEmpty @NotLive @Unmodifiable private final List<Predicate<ProfileRequestContext>> predicates;
+    
+    /**
+     * Constructor.
+     *
+     * @param predicateList the list of predicates to evaluate.
+     */
+    public MultiPredicateAccessPredicate(@Nonnull @NotEmpty 
+            final List<Predicate<ProfileRequestContext>> predicateList) {        
+        predicates = Constraint.isNotNull(CollectionSupport.copyToList(predicateList), 
+                "Predicate list can not be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean test(final ProfileRequestContext prc) {
+        
+        for (final Predicate<ProfileRequestContext> predicate : predicates) {
+            if (!predicate.test(prc)) {
+                return false;
+            }
+        }
+        return true;
+    }
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/RequireStrongFreshAuthnAccessPredicate.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/RequireStrongFreshAuthnAccessPredicate.java
new file mode 100644
index 0000000..0e0f84b
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/RequireStrongFreshAuthnAccessPredicate.java
@@ -0,0 +1,166 @@
+/*
+ * 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.admin.impl;
+
+import java.util.Collection;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.SubjectContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnGuardContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
+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.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * Access control predicate, used within a WebAuthn registration flow, to check if a user with existing WebAuthn/FIDO2 
+ * credentials has performed a fresh, strong, authentication. A fresh and strong authentication is signalled by a
+ * flag in the {@link WebAuthnRegistrationContext} context. The flag is likely set inside the MFA flow logic by
+ * the deployer indicating the expected level of authentication was performed.
+ * 
+ * @since 1.1.0
+ */    
+public class RequireStrongFreshAuthnAccessPredicate extends AbstractIdentifiableInitializableComponent  
+            implements Predicate<ProfileRequestContext>{
+    
+    /** Class logger. */
+    @Nonnull @NotEmpty 
+    private final Logger log = LoggerFactory.getLogger(RequireStrongFreshAuthnAccessPredicate.class);
+    
+    /** Lookup strategy to locate the WebAuthn guard context. */
+    @Nonnull private 
+    Function<ProfileRequestContext,WebAuthnGuardContext> webAuthnGuardContextLookupStrategy;
+    
+    /** Strategy function to lookup SubjectContext. */
+    @Nonnull private Function<ProfileRequestContext,SubjectContext> subjectContextLookupStrategy;
+    
+    /** The credential repository.*/
+    @NonnullAfterInit private WebAuthnCredentialRepository credentialRepository;
+    
+    /** Constructor.*/
+    public RequireStrongFreshAuthnAccessPredicate() {
+        webAuthnGuardContextLookupStrategy = new ChildContextLookup<>(WebAuthnGuardContext.class).compose(
+                new ChildContextLookup<>(AuthenticationContext.class));
+        subjectContextLookupStrategy = new ChildContextLookup<>(SubjectContext.class);
+    }
+    
+    /**
+     * Set the lookup strategy to use to locate the {@link WebAuthnGuardContext}. 
+     * 
+     * @param strategy lookup function to use
+     */
+    public void setWebAuthnGuardContextLookupStrategy(
+            final Function<ProfileRequestContext, WebAuthnGuardContext> strategy) {
+        checkSetterPreconditions();
+        webAuthnGuardContextLookupStrategy = Constraint.isNotNull(strategy,
+                "WebAuthnGuardContextLookupStrategy can not be null");
+    }
+    
+    /**
+     * Set the lookup strategy to use to locate the {@link SubjectContext}.
+     * 
+     * @param strategy lookup function to use
+     */
+    public void setSubjectContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,SubjectContext> strategy) {
+        checkSetterPreconditions();
+        subjectContextLookupStrategy = Constraint.isNotNull(strategy, "SubjectContext lookup strategy cannot be null");
+    }    
+    
+    /**
+     * Set the credential repository used to store WebAuthn credentials.
+     *  
+     * @param repository The repository to set.
+     */
+    public void setCredentialRepository(@Nonnull final WebAuthnCredentialRepository repository) {
+        checkSetterPreconditions();
+        credentialRepository = Constraint.isNotNull(repository, "Credential respository can not be null");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {        
+        super.doInitialize();
+        
+        if (credentialRepository == null) {
+            throw new ComponentInitializationException("Credential respository can not be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean test(@Nullable final ProfileRequestContext profileRequestContext) {
+        if (profileRequestContext == null) {
+            log.debug("ProfileRequestContext is not available, access control predicate can not run, denying access");
+            return false;
+        }
+        final WebAuthnGuardContext guardContext = 
+                webAuthnGuardContextLookupStrategy.apply(profileRequestContext);
+        
+        final SubjectContext subjectContext = subjectContextLookupStrategy.apply(profileRequestContext);
+        if (subjectContext == null) {
+            log.debug("{}: No subject context found, access requires authentication.", getId());
+            return false;
+        }
+        final String usernameFromSubjectContext = subjectContext.getPrincipalName();
+        
+        if (StringSupport.trimOrNull(usernameFromSubjectContext) == null) {
+            log.debug("{}: No principal name found in subject context, denying access", getId());
+            return false;
+        }
+        log.trace("{}: Username (principal name) from subject context '{}'", getId(), usernameFromSubjectContext);
+        
+        if (guardContext == null ) {
+            log.debug("{}: WebAuthnGuardContext not found, suggests the authentication is not fresh or the guard "
+                    + "context was not initialised, denying access",  getId());
+            return false;
+        }
+        assert usernameFromSubjectContext != null;
+        final Collection<CredentialRecord> credentials =
+                credentialRepository.getRegistrationsByUsername(usernameFromSubjectContext); 
+        
+        // Does the user have WebAuthn credentials already? If so, deny access if they arn't strongly authenticating
+        final boolean hasCredentials = !credentials.isEmpty();
+        // This needs to be set by the deployer to indicate some kind of strong authentication was used
+        final boolean isStronglyAuthenticated = guardContext.isStronglyAuthenticated();
+        
+        log.debug("{}: Does user '{}' have FIDO2 credentials '{}', did they strongly authenticate '{}'",  getId(),
+                usernameFromSubjectContext, hasCredentials ? "yes" : "no", isStronglyAuthenticated ? "yes" : "no");
+        if (hasCredentials && !isStronglyAuthenticated) {
+            log.info("{}: User '{}' has FIDO2 credentials but did not strongly authenticate, denying access",  getId(),
+                    usernameFromSubjectContext);
+            return false;
+        }
+        
+        return true;
+        
+    }
+    
+}
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 5124889..c768d5c 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
@@ -11,8 +11,6 @@
 
     <!-- 
     System beans needed for extension to function, loaded after global.xml.
-    The default template shows an incomplete example authentication flow descriptor which can be
-    removed if not needed 
     -->   
     
     <!-- WebAuthn authentication flow -->
@@ -177,6 +175,9 @@
                 c:claz=" net.shibboleth.idp.plugin.authn.webauthn.principal.WebAuthnUserIdPrinicpal" c:name="WEBAUTHNUSERID" />
         </constructor-arg>
     </bean>
+    
+    <bean id="shibboleth.authn.WebAuthn.MultiPredicateAccessPredicate"
+        class="net.shibboleth.idp.plugin.authn.webauthn.admin.impl.MultiPredicateAccessPredicate" abstract="true" />
 
   
 </beans>
\ No newline at end of file
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/RequireStrongFreshAuthnAccessPredicateTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/RequireStrongFreshAuthnAccessPredicateTest.java
new file mode 100644
index 0000000..47720de
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/RequireStrongFreshAuthnAccessPredicateTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.admin.impl;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.authn.context.SubjectContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnGuardContext;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnTest;
+
+/**
+ * Tests for {@link RequireStrongFreshAuthnAccessPredicate}
+ */
+public class RequireStrongFreshAuthnAccessPredicateTest  extends AbstractWebAuthnTest  {
+    
+    private RequireStrongFreshAuthnAccessPredicate predicate;
+    
+    @Override
+    @BeforeMethod
+    public void setup() throws Exception {
+        super.setup();
+        
+        final SubjectContext subCtx = prc.ensureSubcontext(SubjectContext.class);
+        subCtx.setPrincipalName(USERNAME);
+        
+        predicate = new RequireStrongFreshAuthnAccessPredicate();
+        predicate.setCredentialRepository(credentialRepo);
+        predicate.setId("Mock predicate");
+        predicate.initialize();
+    }
+    
+    @Test
+    public void testFreshAndStrongAuthn() throws Exception {
+        
+        credentialRepo.addRegistrationByUsername(USERNAME,createCredentialRegistration(USERNAME, "Test User"));
+        
+        final WebAuthnGuardContext guardCtx = ac.ensureSubcontext(WebAuthnGuardContext.class);
+        guardCtx.setStronglyAuthenticated(true);
+        
+        final boolean access = predicate.test(prc);
+        assertTrue(access);
+    }
+    
+    @Test
+    public void testNotFreshAndStrongAuthn() throws Exception {
+        
+        credentialRepo.addRegistrationByUsername(USERNAME,createCredentialRegistration(USERNAME, "Test User"));
+        
+        final WebAuthnGuardContext guardCtx = ac.ensureSubcontext(WebAuthnGuardContext.class);
+        guardCtx.setStronglyAuthenticated(false);
+        
+        final boolean access = predicate.test(prc);
+        assertFalse(access);
+    }
+    
+    @Test
+    public void testNoGuardContext() throws Exception {
+        
+        credentialRepo.addRegistrationByUsername(USERNAME,createCredentialRegistration(USERNAME, "Test User"));
+        
+        final boolean access = predicate.test(prc);
+        assertFalse(access);
+    }
+    
+    /* This is allowed, if no webauthn credentials we can use any method to authn.*/
+    @Test
+    public void testNoCredentialsNotFreshAndStrong() throws Exception {
+        
+        final WebAuthnGuardContext guardCtx = ac.ensureSubcontext(WebAuthnGuardContext.class);
+        guardCtx.setStronglyAuthenticated(false);
+        
+        final boolean access = predicate.test(prc);
+        assertTrue(access);
+    }
+
+}
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 ce7b1d2..d085c63 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
@@ -325,18 +325,22 @@ public abstract class AbstractWebAuthnTest {
         assert event != null;
         assertEquals(event.getId(), eventId);
     }
-
+    
     /**
      * Create a credential registration with a new attestation response from the mock authenticator.
      * 
+     * @param username the username
+     * @param dispName the displayName
+     * 
      * @return the credential registration 
      * 
      * @throws Exception on error
      */
-    @Nonnull protected CredentialRecord createCredentialRegistration() throws Exception {         
+    @Nonnull protected CredentialRecord createCredentialRegistration(final String username, final String dispName) 
+            throws Exception {
         final var user = UserIdentity.builder()
-                .name("jdoe")
-                .displayName("John Doe")
+                .name(username)
+                .displayName(dispName)
                 .id(ByteArray.fromBase64(USER_HANDLE_B64))
                 .build();
        
@@ -358,7 +362,7 @@ public abstract class AbstractWebAuthnTest {
          
          final var reg = CredentialRecord.builder()
                  .withUserIdentity(user)
-                 .withUsername("jdoe")
+                 .withUsername(username)
                  .withTransports(new TreeSet<AuthenticatorTransport>())
                  .withRegistrationTime(Instant.now())
                  .withCredential(credential)
@@ -371,6 +375,19 @@ public abstract class AbstractWebAuthnTest {
          
          return reg;
     }
+
+    /**
+     * Create a credential registration with a new attestation response from the mock authenticator. Use a default
+     * username and displayName. 
+     * 
+     * @return the credential registration 
+     * 
+     * @throws Exception on error
+     */
+    @Deprecated
+    @Nonnull protected CredentialRecord createCredentialRegistration() throws Exception {         
+        return createCredentialRegistration("jdoe", "John Doe");
+    }
     
     /**
      * Create a credential registration attestation response from the mock authenticator.

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


More information about the commits mailing list