[java-idp-plugin-duo] branch dev/JDUO-82 updated: Flesh out default enrollment condition and move it to API package.

Scott Cantor cantor.2 at osu.edu
Fri Jan 5 20:41:40 UTC 2024


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

scantor pushed a commit to branch dev/JDUO-82
in repository java-idp-plugin-duo.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-duo.git;a=commit;h=7ce0ad2e76ce31fa90ec2afe69965662ac71b64a

The following commit(s) were added to refs/heads/dev/JDUO-82 by this push:
     new 7ce0ad2e Flesh out default enrollment condition and move it to API package.
7ce0ad2e is described below

commit 7ce0ad2e76ce31fa90ec2afe69965662ac71b64a
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Fri Jan 5 15:41:37 2024 -0500

    Flesh out default enrollment condition and move it to API package.
---
 .../authn/duo/PasswordlessEnrolmentCondition.java  | 130 +++++++++++++++++++++
 .../idp/plugin/authn/duo/model/User.java           |  24 ++--
 .../plugin/authn/duo/model/WebAuthnCredential.java |   2 +-
 .../impl/PasswordlessEnrolmentCheckCondition.java  |  74 ------------
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  40 ++++---
 5 files changed, 164 insertions(+), 106 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrolmentCondition.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrolmentCondition.java
new file mode 100644
index 00000000..6071abf3
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrolmentCondition.java
@@ -0,0 +1,130 @@
+/*
+ * 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.duo;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.function.BiPredicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.authn.duo.model.User;
+import net.shibboleth.idp.plugin.authn.duo.model.WebAuthnCredential;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * A BiPredicate which checks the enrollment status of a user against the Duo Admin APIs.
+ * 
+ * <p>The second parameter is the username to evaluate.</p>
+ */
+
+public class PasswordlessEnrolmentCondition extends AbstractInitializableComponent
+        implements BiPredicate<ProfileRequestContext,String> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(PasswordlessEnrolmentCondition.class);
+    
+    /** The admin client used to access the DuoAdmin API.*/
+    @NonnullAfterInit private DuoAdminClient adminClient;
+
+    /** Allowed WebAuthnCredential labels that qualify. */
+    @Nonnull @NonnullElements private Set<String> allowedLabels;
+    
+    /** Constructor. */
+    public PasswordlessEnrolmentCondition() {
+        allowedLabels = CollectionSupport.emptySet();
+    }
+    
+    /**
+     * Sets the {@link DuoAdminClient} to use.
+     * 
+     * @param client admin client
+     */
+    public void setDuoAdminClient(@Nonnull final DuoAdminClient client) {
+        checkSetterPreconditions();
+        adminClient = client;
+    }
+    
+    /**
+     * Sets the allowable {@link WebAuthnCredential} labels that qualify for the condition.
+     * 
+     * @param labels credential labels
+     */
+    public void setAllowedLabels(@Nonnull @NonnullElements final Collection<String> labels) {
+        checkSetterPreconditions();
+        allowedLabels = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(labels));
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    public void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (adminClient == null) {
+            throw new ComponentInitializationException("DuoAdminClient cannot be null");
+        }
+    }
+    
+    /** {@inheritDoc} */
+    public boolean test(@Nullable final ProfileRequestContext profileRequestContext, @Nullable final String username) {
+        checkComponentActive();
+        
+        if (profileRequestContext == null || username == null) {
+            log.trace("Unable to checl passwordless enrollment status for '{}'", username);
+            return false;
+        }
+        
+        log.trace("Checking passwordless enrollment status for '{}'", username); 
+        try {
+            final User response = adminClient.getUser(profileRequestContext, username);
+            final Boolean enrolled = response.isEnrolled();
+            
+            if (enrolled == null || !enrolled) {
+                log.trace("User '{}' not enrolled", response.getUserId());
+                return false;
+            }
+            
+            final List<WebAuthnCredential> creds = response.getWebAuthnCredentials();
+            if (creds.isEmpty()) {
+                log.trace("User '{}' has no WebAuthn credentials", response.getUserId());
+                return false;
+            }
+            
+            log.trace("User '{}' is enrolled with {} WebAuthn credential(s)", response.getUserId(),
+                    response.getWebAuthnCredentials().size());
+            
+            final boolean allowed = creds.stream().map(WebAuthnCredential::getLabel).anyMatch(allowedLabels::contains);
+            log.debug("User '{}' {} a qualifying WebAuthn credential", response.getUserId(),
+                    allowed ? "has" : "does not have");
+            return allowed;
+            
+        } catch (final DuoException e) {
+            log.warn("Duo AdminAPI request failed, denying passwordless for '{}'", username, e);
+            return false;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
index b43acc0f..e95122b0 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
@@ -42,7 +42,7 @@ import net.shibboleth.shared.collection.CollectionSupport;
 @JsonInclude(JsonInclude.Include.NON_NULL)
 @JsonIgnoreProperties(ignoreUnknown = true) 
 @JsonDeserialize(builder = User.Builder.class)
-public class User {
+public final class User {
 
     /** Generated serialUID. */
     public static final long serialVersionUID = 8122423604710803224L;
@@ -68,8 +68,8 @@ public class User {
 
     /** 
      * An integer indicating the last update to the user via directory sync as a Unix timestamp, or null if the user 
-     * has never synced with an external directory or if the directory that originally created the user has been deleted 
-     * from Duo.
+     * has never synced with an external directory or if the directory that originally created the user has been
+     * deleted from Duo.
      */
     @JsonProperty("last_directory_sync")
     @Nullable private final Object lastDirectorySync;
@@ -147,9 +147,9 @@ public class User {
     }
 
     /**
-     * Get The user's creation date as a UNIX timestamp.
+     * Get the user's creation date as a UNIX timestamp.
      * 
-     * @return
+     * @return creation date
      */
     @JsonProperty("created")
     @Nullable public Integer getCreated() {
@@ -159,7 +159,7 @@ public class User {
     /**
      * Get the user's email address.
      * 
-     * @return
+     * @return email address
      */
     @JsonProperty("email")
     @Nullable public String getEmail() {
@@ -169,7 +169,7 @@ public class User {
     /**
      * Get The user's given name.
      * 
-     * @return
+     * @return given name
      */
     @JsonProperty("firstname")
     @Nullable public Object getFirstname() {
@@ -183,7 +183,7 @@ public class User {
      * @return if the user has enrolled
      */
     @JsonProperty("is_enrolled")
-    public Boolean isEnrolled() {
+    @Nullable public Boolean isEnrolled() {
         return enrolled;
     }
 
@@ -192,7 +192,7 @@ public class User {
      * the user has never synced with an external directory or if the directory that originally created the 
      * user has been deleted from Duo.
      * 
-     * @return
+     * @return last directory sync.
      */
     @JsonProperty("last_directory_sync")
     @Nullable public Object getLastDirectorySync() {
@@ -203,7 +203,7 @@ public class User {
      * Get an integer indicating the last time this user logged in, as a Unix timestamp, or null if the user 
      * has not logged in.
      * 
-     * @return
+     * @return last login time
      */
     @JsonProperty("last_login")
     @Nullable public Integer getLastLogin() {
@@ -319,10 +319,11 @@ public class User {
         return new Builder();
     }
 
+    /** The builder used to create a threadsafe instance of this class.*/
     @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "with")
     @JsonIgnoreProperties(ignoreUnknown = true) 
-    /** The builder used to create a threadsafe instance of this class.*/
     public static final class Builder {
+        
         @Nullable private Integer created;
         @Nullable private String email;
         @Nullable private String firstname;
@@ -454,6 +455,5 @@ public class User {
             return new User(this);
         }
     }
-    
 
 }
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
index c36a71fd..64f08a83 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
@@ -26,7 +26,7 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
  */
 @JsonInclude(JsonInclude.Include.NON_NULL)
 @JsonDeserialize(builder = WebAuthnCredential.Builder.class)
-public class WebAuthnCredential {
+public final class WebAuthnCredential {
 
     /** Free-form label for the WebAuthn credential.*/
     @JsonProperty("credential_name")
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PasswordlessEnrolmentCheckCondition.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PasswordlessEnrolmentCheckCondition.java
deleted file mode 100644
index a78a090c..00000000
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PasswordlessEnrolmentCheckCondition.java
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * 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.duo.impl;
-
-import java.util.function.BiPredicate;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-
-import org.opensaml.profile.context.ProfileRequestContext;
-import org.slf4j.Logger;
-
-import net.shibboleth.idp.plugin.authn.duo.DuoException;
-import net.shibboleth.idp.plugin.authn.duo.model.User;
-import net.shibboleth.shared.annotation.ParameterName;
-import net.shibboleth.shared.logic.Constraint;
-import net.shibboleth.shared.primitive.LoggerFactory;
-
-/**
- * A predicate which checks the enrolment status of a user against the Duo Admin APIs.  Accepts a profile request
- * context tree and a username string.
- */
-
-//TODO FIXME This is only an example
-
-public class PasswordlessEnrolmentCheckCondition implements BiPredicate<ProfileRequestContext,String> {
-    
-    /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(PasswordlessEnrolmentCheckCondition.class);
-    
-    /** The admin client used to access the DuoAdmin API.*/
-    @Nonnull private final DefaultDuoAdminClient adminClient;
-    
-    public PasswordlessEnrolmentCheckCondition(@ParameterName(name="client") @Nonnull final DefaultDuoAdminClient client) {
-        adminClient = Constraint.isNotNull(client, "Duo Admin Client can not be null");
-    }    
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean test(@Nullable final ProfileRequestContext prc, @Nullable final String username) {
-        
-        if (prc == null || username == null) {
-            log.trace("Unable to checl passwordless enrollment status for '{}'", username);
-            return false;
-        }
-        
-        log.trace("Checking passwordless enrollment status for '{}'", username); 
-        try {
-            final User response = adminClient.getUser(prc, username);
-            log.trace("User '{}' isEnrolled '{}' and has '{}' credentials", response.getUserId(), response.isEnrolled(), 
-                    response.getWebAuthnCredentials().size());            
-            if (response.isEnrolled() == null) {
-                return false;
-            }
-            return response.isEnrolled();
-        } catch (final DuoException ex) {
-            log.warn("Duo AdminAPI request failed: '{}'", ex.getMessage(), ex);
-            return false;
-        }       
-    }
-
-}
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
index fa3bb838..c93bda6f 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
@@ -62,16 +62,16 @@
 
     <!-- Default passwordless integration. -->
     <bean id="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegration" lazy-init="false"
-        class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
-        p:passwordless="true"
-        p:APIHost="%{idp.duo.oidc.passwordless.apiHost:%{idp.duo.oidc.apiHost:none}}"
-        p:clientId="%{idp.duo.oidc.passwordless.clientId:none}"
-        p:secretKey="%{idp.duo.oidc.passwordless.secretKey:none}"
-        p:registeredRedirectURI="%{idp.duo.oidc.passwordless.redirectURL:%{idp.duo.oidc.redirectURL:}}"
-        p:healthCheckEndpoint="%{idp.duo.oidc.passwordless.endpoint.health:%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}}"
-        p:tokenEndpoint="%{idp.duo.oidc.passwordless.endpoint.token:%{idp.duo.oidc.endpoint.token:/oauth/v1/token}}"
-        p:authorizeEndpoint="%{idp.duo.oidc.passwordless.endpoint.authorize:%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}}"
-        p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}">
+            class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
+            p:passwordless="true"
+            p:APIHost="%{idp.duo.oidc.passwordless.apiHost:%{idp.duo.oidc.apiHost:none}}"
+            p:clientId="%{idp.duo.oidc.passwordless.clientId:none}"
+            p:secretKey="%{idp.duo.oidc.passwordless.secretKey:none}"
+            p:registeredRedirectURI="%{idp.duo.oidc.passwordless.redirectURL:%{idp.duo.oidc.redirectURL:}}"
+            p:healthCheckEndpoint="%{idp.duo.oidc.passwordless.endpoint.health:%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}}"
+            p:tokenEndpoint="%{idp.duo.oidc.passwordless.endpoint.token:%{idp.duo.oidc.endpoint.token:/oauth/v1/token}}"
+            p:authorizeEndpoint="%{idp.duo.oidc.passwordless.endpoint.authorize:%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}}"
+            p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}">
         <property name="allowedFactors">
             <bean parent="shibboleth.CommaDelimStringArray"
                 c:_0="#{'%{idp.duo.oidc.passwordless.allowedFactors:Platform authenticator (2fa),Roaming authenticator (2fa)}'.trim()}" />
@@ -83,7 +83,7 @@
     <!-- Default Duo Admin API Integration -->
     <bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" lazy-init="false"
         class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
-        p:APIHost="%{idp.duo.oidc.admin.apiHost:none}"
+        p:APIHost="%{idp.duo.oidc.admin.apiHost:%{idp.duo.oidc.apiHost:none}}"
         p:integrationKey="%{idp.duo.oidc.admin.integrationKey:none}"
         p:secretKey="%{idp.duo.oidc.admin.secretKey:none}"/>
     <bean id="shibboleth.authn.DuoOIDC.Admin.DuoAdminIntegrationStrategy" parent="shibboleth.Functions.Constant"
@@ -155,8 +155,6 @@
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
     <!-- Passwordless beans  -->
-    <!-- TODO: change to shibboleth.BiConditions.TRUE once API moves to 5.1. -->
-    <bean id="DefaultPasswordlessCondition" parent="shibboleth.BiConditions.Expression" c:_0="true" />
     <bean id="CheckPasswordlessEnrollment"
         class="net.shibboleth.idp.plugin.authn.duo.impl.CheckPasswordlessEnrollment" scope="prototype"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
@@ -171,14 +169,18 @@
         p:trim="%{idp.authn.oidc.trim:true}"
         p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}" />
     
-    <!-- Example passwordless condition that uses the admin API -->
-    <bean id="shibboleth.authn.DuoOIDC.Passwordless.Condition" 
-        class="net.shibboleth.idp.plugin.authn.duo.impl.PasswordlessEnrolmentCheckCondition"
-        c:_0="#{getObject('shibboleth.authn.DuoOIDC.Admin.Api.Client') ?: getObject('DefaultDuoAdminClient')}"/>
-        
+    <!-- Default passwordless condition that uses the admin API -->
+    <bean id="DefaultPasswordlessCondition"
+            class="net.shibboleth.idp.plugin.authn.duo.PasswordlessEnrolmentCondition"
+            p:duoAdminClient="#{getObject('shibboleth.authn.DuoOIDC.AdminClient') ?: getObject('DefaultDuoAdminClient')}">
+        <property name="allowedLabels">
+            <bean parent="shibboleth.CommaDelimStringArray"
+                c:_0="#{'%{idp.duo.oidc.passwordless.allowedLabels:}'.trim()}" />
+        </property>
+    </bean>
                 
     <!-- Singleton, shared, Duo Admin API Beans -->
-    <bean id="DefaultDuoAdminClient" class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoAdminClient" scope="singleton"
+    <bean id="DefaultDuoAdminClient" class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoAdminClient"
         p:objectMapper-ref="shibboleth.JSONObjectMapper"
         p:httpClient="#{getObject('shibboleth.authn.DuoOIDC.Admin.HttpClient') ?: getObject('shibboleth.InternalHttpClient')}"
         p:httpClientSecurityParameters="#{getObject('shibboleth.authn.DuoOIDC.NonBrowser.HttpClientSecurityParameters')}"

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


More information about the commits mailing list