[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