[java-idp-plugin-duo] branch main updated: JDUO-80 - Use of Duo as a Passwordless solution

Scott Cantor cantor.2 at osu.edu
Tue Jan 16 20:04:53 UTC 2024


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

scantor pushed a commit to branch main
in repository java-idp-plugin-duo.

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

The following commit(s) were added to refs/heads/main by this push:
     new edacdea9 JDUO-80 - Use of Duo as a Passwordless solution
edacdea9 is described below

commit edacdea939bd979ccbaaf76c56cf6597c6e20e67
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Jan 16 15:04:51 2024 -0500

    JDUO-80 - Use of Duo as a Passwordless solution
    
    https://shibboleth.atlassian.net/browse/JDUO-80
    
    Redo form (again) using single form design.
    Add detection of UnknownUsername event and handle empty form variable.
    Do enrollment check conditionally, defaulting only after form submit.
    Switch allowed factor default to platform authenticators only.
---
 .../duo/impl/CheckPasswordlessEnrollment.java      | 68 +++++++++++++++-------
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  7 ++-
 .../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml    |  4 +-
 .../idp/plugin/authn/duo/messages.properties       |  3 +-
 .../idp/plugin/authn/duo/views/passwordless.vm     | 25 +++-----
 .../duo/nimbus/conf/authn/duo-oidc.properties      |  2 +
 .../authn/duo/sdk/conf/authn/duo-oidc.properties   |  2 +
 7 files changed, 68 insertions(+), 43 deletions(-)

diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
index b7948d65..c8aebbbd 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
@@ -83,6 +83,9 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
     /** Condition indicating passwordless use is valid. */
     @Nonnull private BiPredicate<ProfileRequestContext,String> passwordlessCondition;
 
+    /** Whether to carry out the full enrollment checking step. */
+    private boolean checkEnrollment;
+    
     /** Form parameter name to carry username. */
     @Nonnull @NotEmpty private String usernameFieldName;
 
@@ -113,6 +116,8 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
                 new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
                         new ChildContextLookup<>(AuthenticationContext.class));
         
+        checkEnrollment = true;
+        
         // TODO: BiPredicateSupport.alwaysTrue once API is bumped.
         passwordlessCondition = (a,b) -> {
             return true;
@@ -148,6 +153,19 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
         passwordlessCondition = Constraint.isNotNull(condition, "Condition cannot be null");
     }
     
+    /**
+     * Sets whether the action should carry out the enrollment check or stop at populating the username field.
+     * 
+     * <p>Defaults to true.</p>
+     * 
+     * @param flag flag to set
+     */
+    public void setCheckEnrollment(final boolean flag) {
+        checkSetterPreconditions();
+        
+        checkEnrollment = flag;
+    }
+    
     /**
      * Sets the name of the form field to carry the username.
      * 
@@ -245,6 +263,7 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
             log.debug("{} No DuoPasswordlessContext found, nothing to do", getLogPrefix());
             return false;
         }
+        
         return true;
     }
 
@@ -312,26 +331,31 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
             }
         }
         
-        if (passwordlessContext.getUsername() == null) {
+        final String finalUsername = passwordlessContext.getUsername(); 
+        if (finalUsername == null || finalUsername.isBlank()) {
+            passwordlessContext.setUsername(null);
             passwordlessContext.setEnrolled(false);
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
-            return;
+            if (checkEnrollment) {
+                ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
+                return;
+            }
         }
         
         if (usernameChanged) {
-            passwordlessContext.setEnrolled(
-                    passwordlessCondition.test(profileRequestContext, passwordlessContext.getUsername()));
-            log.debug("{} Username '{}' found to be {} of passwordless attempt", getLogPrefix(),
-                    passwordlessContext.getUsername(), passwordlessContext.isEnrolled() ? "capable" : "incapable");
-
-            // Upodate cookie if needed.
-            manageCookie(authenticationContext);
-            
+            if (checkEnrollment) {
+                passwordlessContext.setEnrolled(
+                        passwordlessCondition.test(profileRequestContext, finalUsername));
+                log.debug("{} Username '{}' found to be {} of passwordless attempt", getLogPrefix(), finalUsername,
+                        passwordlessContext.isEnrolled() ? "capable" : "incapable");
+                
+                // Upodate cookie if needed.
+                manageCookie(authenticationContext);
+            }
         } else {
             log.debug("{} Username not updated, leaving DuoPasswordlessContext unchanged", getLogPrefix());
         }
         
-        if (!passwordlessContext.isEnrolled()) {
+        if (checkEnrollment && !passwordlessContext.isEnrolled()) {
             ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
         }
     }
@@ -389,8 +413,6 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
     /**
      * Gets the username from a form submission.
      * 
-     * <p>Also processes do-not-cache instruction.</p>
-     * 
      * @param profileRequestContext profile request context
      * @param authenticationContext authentication context
      * 
@@ -401,11 +423,13 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
         
         final HttpServletRequest request = getHttpServletRequest();
         if (request != null) {
-            // TODO: Convert to 2 parameter version once API moves to 5.1.
-            final String s = applyTransforms(request.getParameter(usernameFieldName));
-            
-            return duoUsernameRemappingStrategy != null
-                    ? duoUsernameRemappingStrategy.apply(profileRequestContext, s) : s;
+            final String param = request.getParameter(usernameFieldName);
+            if (param != null) {
+                // TODO: Convert to 2 parameter version once API moves to 5.1.
+                final String s = applyTransforms(param);
+                return duoUsernameRemappingStrategy != null
+                        ? duoUsernameRemappingStrategy.apply(profileRequestContext, s) : s;
+            }
         }
         
         return null;
@@ -418,7 +442,8 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
      * 
      * @return username from existing sealed cookie, or null
      */
-    @Nullable private String getUsernameFromCookie(@Nonnull final ProfileRequestContext profileRequestContext) {
+    @Nullable @NotEmpty private String getUsernameFromCookie(
+            @Nonnull final ProfileRequestContext profileRequestContext) {
         
         if (cookieManager != null && dataSealer != null && cookieName != null) {
             final String cookie = URISupport.doURLDecode(cookieManager.getCookieValue(cookieName, null));
@@ -446,7 +471,8 @@ public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
      * 
      * @return username from existing session, or null
      */
-    @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext,
+    @Nullable @NotEmpty private String getUsernameFromSession(
+            @Nonnull final ProfileRequestContext profileRequestContext,
             @Nonnull final AuthenticationContext authenticationContext) {
         
         if (!authenticationContext.getActiveResults().isEmpty()) {
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 3f4d7263..a1bf28ae 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
@@ -74,7 +74,7 @@
             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()}" />
+                c:_0="#{'%{idp.duo.oidc.passwordless.allowedFactors:Platform authenticator (2fa)}'.trim()}" />
         </property>
     </bean>
     <bean id="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
@@ -146,7 +146,7 @@
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
     <!-- Passwordless beans  -->
-    <bean id="CheckPasswordlessEnrollment"
+    <bean id="CheckPasswordlessEnrollment2"
             class="net.shibboleth.idp.plugin.authn.duo.impl.CheckPasswordlessEnrollment" scope="prototype"
             p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
             p:passwordlessCondition="#{getObject('shibboleth.authn.DuoOIDC.Passwordless.Condition') ?: getObject('shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition')}"
@@ -164,6 +164,9 @@
                 c:_0="#{'%{idp.authn.usernamePrecedence:form,session,cookie}'.trim()}" />
         </property>
     </bean>
+    <!-- Same bean as above but property-based enrollment check for first iteration of loop. -->
+    <bean id="CheckPasswordlessEnrollment1" parent="CheckPasswordlessEnrollment2" scope="prototype"
+        p:checkEnrollment="%{idp.duo.oidc.passwordless.checkEnrollmentOnEntry:false}" />
     
     <!-- Duo OIDC beans -->
     <bean id="PopulateDuoAuthenticationContext" scope="prototype"
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
index e01c7ed2..981ac8b2 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
@@ -30,7 +30,7 @@
     </decision-state>
     
     <action-state id="CheckPasswordlessEnrollment1">
-        <evaluate expression="CheckPasswordlessEnrollment" />
+        <evaluate expression="CheckPasswordlessEnrollment1" />
         <evaluate expression="'proceed'" />
         
         <transition on="proceed" to="PasswordlessView" />
@@ -58,7 +58,7 @@
     </view-state>
     
     <action-state id="CheckPasswordlessEnrollment2">
-        <evaluate expression="CheckPasswordlessEnrollment" />
+        <evaluate expression="CheckPasswordlessEnrollment2" />
         <evaluate expression="'proceed'" />
         
         <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
index a4af41d0..db2f051b 100644
--- a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
@@ -15,7 +15,8 @@ idp.duo.passwordless.explain = If you've enrolled a passkey or device/token for
 idp.duo.passwordless.proceed = Login with Passkey or Device
 idp.duo.passwordless.cancel = Login with Password
 
-idp.duo.passwordless.unsupported = You have not enrolled a qualifying device for Passwordless use.
+idp.duo.passwordless.unsupported = The specified user has not enrolled a qualifying device for Passwordless use.
+idp.duo.passwordless.username = Please enter your username before attempting a Passwordless login.
 
 idp.duo.enrollment = Enroll New Devices
 idp.duo.enrollment.url = #
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
index d7294fd4..25eb3ca5 100644
--- a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
@@ -25,8 +25,6 @@
 #end
 #set ($nonce = $cspNonce.generateIdentifier())
 $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
-#set ($onClick = "document.forms.password.j_username.value = document.forms.passwordless.j_username.value")
-$response.addHeader("Content-Security-Policy", "script-src-attr 'unsafe-hashes' 'sha256-$cspDigester.apply($onClick)'")
 ##
 <!DOCTYPE html>
 <html>
@@ -69,28 +67,17 @@ $response.addHeader("Content-Security-Policy", "script-src-attr 'unsafe-hashes'
                     <p>$encoder.encodeForHTML($desc)</p>
                 #end
 
-                <!-- Two forms are used below to allow the return key to trigger the Passwordless option. -->
-
                 <blockquote>#springMessageText("idp.duo.passwordless.explain", "If you've enrolled a passkey or device/token for passwordless login,
                 please enter your username below and press the corresponding button. To bypass this option, just press the alternate button
                 to perform a traditional login.")</blockquote>
-
-                <form name="password" action="$flowExecutionUrl" method="post">
-                    #parse("csrf/csrf.vm")
-                    <input name="j_username" type="hidden" value="" />
-                    <div class="grid">
-                        <div class="grid-item">
-                            <button type="submit" name="_eventId_cancel" onClick="$onClick"
-                                >#springMessageText("idp.duo.passwordless.cancel", "Login with Password")</button>
-                        </div>
-                    </div>
-                </form>
                 
                 #if ($eventId == "RequestUnsupported")
-                    <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.unsupported', 'You have not enrolled a qualifying device for Passwordless use.')")</p>
+                    <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.unsupported', 'The specified user has not enrolled a qualifying device for Passwordless use.')")</p>
+                #elseif ($eventId == "UnknownUsername")
+                    <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.username', 'Please enter your username before attempting a Passwordless login.')")</p>
                 #end
                         
-                <form name="passwordless" action="$flowExecutionUrl" method="post">
+                <form action="$flowExecutionUrl" method="post">
                     #parse("csrf/csrf.vm")
                     
                     <label for="username">#springMessageText("idp.login.username", "Username")</label>
@@ -108,6 +95,10 @@ $response.addHeader("Content-Security-Policy", "script-src-attr 'unsafe-hashes'
                             <button type="submit" name="_eventId_proceed"
                                 >#springMessageText("idp.duo.passwordless.proceed", "Login with Passkey or Device")</button>
                         </div>
+                        <div class="grid-item">
+                            <button type="submit" name="_eventId_cancel" onClick="$onClick"
+                                >#springMessageText("idp.duo.passwordless.cancel", "Login with Password")</button>
+                        </div>
                     </div>
                 </form>
     
diff --git a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
index 530e8f10..760de3c2 100644
--- a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
+++ b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
@@ -40,6 +40,8 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 #idp.duo.oidc.passwordless.integrationKey = ikey
 # Suggest defining this in credentials/secrets.properties
 #idp.duo.oidc.passwordless.secretKey = key
+# Controls whether a cached username is initially run through the check
+#idp.duo.oidc.passwordless.checkEnrollmentOnEntry = false
 
 # Non-Browser AuthAPI integration if desired
 #idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}
diff --git a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
index 98c58e44..9d9701fb 100644
--- a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
+++ b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
@@ -40,6 +40,8 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 #idp.duo.oidc.passwordless.integrationKey = ikey
 # Suggest defining this in credentials/secrets.properties
 #idp.duo.oidc.passwordless.secretKey = key
+# Controls whether a cached username is initially run through the check
+#idp.duo.oidc.passwordless.checkEnrollmentOnEntry = false
 
 # Non-Browser AuthAPI integration if desired
 #idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}

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


More information about the commits mailing list