[java-identity-provider] branch main updated: IDP-2220 - Add username caching for login form

Scott Cantor cantor.2 at osu.edu
Wed Jan 3 18:56:47 UTC 2024


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

scantor pushed a commit to branch main
in repository java-identity-provider.

View the commit online:
http://git.shibboleth.net/view/?p=java-identity-provider.git;a=commit;h=68c1cf161847e15f396796a2c1b779ad9b6e2599

The following commit(s) were added to refs/heads/main by this push:
     new 68c1cf161 IDP-2220 - Add username caching for login form
68c1cf161 is described below

commit 68c1cf161847e15f396796a2c1b779ad9b6e2599
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Wed Jan 3 13:56:43 2024 -0500

    IDP-2220 - Add username caching for login form
    
    https://shibboleth.atlassian.net/browse/IDP-2220
    
    Add username caching/loading to password flow.
---
 .../idp/authn/impl/PrePopulateUsername.java        | 280 +++++++++++++++++++++
 .../idp/authn/impl/ValidateCredentials.java        |  92 ++++++-
 .../idp/flows/authn/password-authn-beans.xml       |  14 +-
 .../idp/flows/authn/password-authn-flow.xml        |  12 +-
 .../idp/module/conf/authn/authn.properties         |   6 +
 5 files changed, 394 insertions(+), 10 deletions(-)

diff --git a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/PrePopulateUsername.java b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/PrePopulateUsername.java
new file mode 100644
index 000000000..40b181a45
--- /dev/null
+++ b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/PrePopulateUsername.java
@@ -0,0 +1,280 @@
+/*
+ * 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.authn.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.authn.AbstractExtractionAction;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.UsernamePasswordContext;
+import net.shibboleth.idp.session.IdPSession;
+import net.shibboleth.idp.session.context.SessionContext;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.net.URISupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * An action to populate a username into a cleared {@link UsernamePasswordContext}, either from a form
+ * submission, a cookie, or an existing session to "prime" the login view.
+ * 
+ * <p>Because this action is essentially a UI optimization, it's forgiving of errors or problems
+ * it encounters and will only warn.</p>
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @post {@link UsernamePasswordContext#setUsername(String)} is called with an existing value if found
+ * 
+ * @since 5.1.0
+ */
+public class PrePopulateUsername extends AbstractExtractionAction {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(PrePopulateUsername.class);
+    
+    /** Strategy used to create/locate the {@link UsernamePasswordContext} to operate on. */
+    @Nonnull private Function<ProfileRequestContext,UsernamePasswordContext> usernamePasswordContextCreationStrategy;
+
+    /** Form parameter name to carry username. */
+    @Nonnull @NotEmpty private String usernameFieldName;
+
+    /** Username cookie name. */
+    @Nullable @NotEmpty private String cookieName;
+    
+    /** Optional cookie manager to use. */
+    @Nullable private CookieManager cookieManager;
+
+    /** Optional data sealer to use. */
+    @Nullable private DataSealer dataSealer;
+    
+    /** Whether to pull username from existing session or not. */
+    private boolean checkSession;
+    
+    /** Context to operate on. */
+    @NonnullBeforeExec private UsernamePasswordContext usernameContext;
+    
+    /** Constructor.*/
+    public PrePopulateUsername() {
+        usernamePasswordContextCreationStrategy =
+                new ChildContextLookup<>(UsernamePasswordContext.class).compose(
+                        new ChildContextLookup<>(AuthenticationContext.class));
+            
+        usernameFieldName = "j_username";
+    }
+    
+    /**
+     * Set the strategy used to create/locate the {@link UsernamePasswordContext} to operate on.
+     * 
+     * @param strategy creation strategy
+     */
+    public void setUsernamePasswordContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,UsernamePasswordContext> strategy) {
+        checkSetterPreconditions();
+
+        usernamePasswordContextCreationStrategy =
+                Constraint.isNotNull(strategy, "DuoPasswordlessContext lookup strategy cannot be null");
+    }
+    
+    /**
+     * Sets the name of the form field to carry the username.
+     * 
+     * @param name field name
+     */
+    public void setUsernameFieldName(@Nonnull final String name) {
+        checkSetterPreconditions();
+        
+        usernameFieldName = Constraint.isNotNull(StringSupport.trimOrNull(name) ,
+                "Username form field name cannot be null or empty");
+    }
+
+    /**
+     * Set cookie name to use for cached username.
+     * 
+     * @param name cookie name
+     */
+    public void setCookieName(@Nullable final String name) {
+        checkSetterPreconditions();
+
+        cookieName = StringSupport.trimOrNull(name);
+    }
+
+    /**
+     * Sets optional {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager
+     */
+    public void setCookieManager(@Nullable final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = manager;
+    }
+
+    /**
+     * Sets optional {@link DataSealer} to use.
+     * 
+     * @param sealer data sealer
+     */
+    public void setDataSealer(@Nullable final DataSealer sealer) {
+        checkSetterPreconditions();
+        
+        dataSealer = sealer;
+    }
+    
+    /**
+     * Sets whether tp pull username from existing session as a fallback.
+     * 
+     * <p>Defaults to false.</p>
+     * 
+     * @param flag flag to set
+     */
+    public void setCheckSession(final boolean flag) {
+        checkSetterPreconditions();
+
+        checkSession = flag;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        if (!super.doPreExecute(profileRequestContext, authenticationContext)) {
+            return false;
+        }
+        
+        usernameContext = usernamePasswordContextCreationStrategy.apply(profileRequestContext);
+        if (usernameContext == null) {
+            log.warn("{} Unable to create UsernamePasswordContext, skipping action", getLogPrefix());
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        usernameContext.setUsername(null);
+        usernameContext.setPassword(null);
+
+        String username = getUsernameFromForm(authenticationContext);
+        if (username != null && !username.isEmpty()) {
+            log.debug("{} Populating username '{}' from form submission into UsernamePasswordContext",
+                    getLogPrefix(), username);
+            usernameContext.setUsername(username);
+            return;
+        }
+
+        username = getUsernameFromCookie(profileRequestContext);
+        if (username != null && !username.isEmpty()) {
+            log.debug("{} Populating cached username '{}' from cookie into UsernamePasswordContext",
+                    getLogPrefix(), username);
+            usernameContext.setUsername(username);
+        }
+
+        username = getUsernameFromSession(profileRequestContext, authenticationContext);
+        if (username != null && !username.isEmpty()) {
+            log.debug("{} Populating username '{}' from session into UsernamePasswordContext", getLogPrefix(),
+                    username);
+            usernameContext.setUsername(username);
+        }
+    }
+    
+    /**
+     * Gets the username from a form submission.
+     * 
+     * <p>Also processes do-not-cache instruction.</p>
+     * 
+     * @param authenticationContext authentication context
+     * 
+     * @return submitted username, after applying any configured transforms
+     */
+    @Nullable private String getUsernameFromForm(@Nonnull final AuthenticationContext authenticationContext) {
+        
+        final HttpServletRequest request = getHttpServletRequest();
+        if (request != null) {
+            return applyTransforms(request.getParameter(usernameFieldName));
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Gets the username from an existing sealed cookie, if any.
+     * 
+     * @param profileRequestContext profile request context
+     * 
+     * @return username from existing sealed cookie, or null
+     */
+    @Nullable private String getUsernameFromCookie(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        if (cookieManager != null && dataSealer != null && cookieName != null) {
+            final String cookie = URISupport.doURLDecode(cookieManager.getCookieValue(cookieName, null));
+            if (cookie != null) {
+                try {
+                    assert dataSealer != null;
+                    return dataSealer.unwrap(cookie);
+                } catch (final DataSealerException e) {
+                    log.warn("{} Unable to unwrap sealed username cookie", getLogPrefix(), e);
+                    assert cookieName != null;
+                    assert cookieManager != null;
+                    cookieManager.unsetCookie(cookieName);
+                }
+            }
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Gets the username from an existing {@link IdPSession}, if any.
+     * 
+     * @param profileRequestContext profile request context
+     * @param authenticationContext authentication context
+     * 
+     * @return username from existing session, or null
+     */
+    @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        if (checkSession && !authenticationContext.getActiveResults().isEmpty()) {
+            final SessionContext sessionContext = profileRequestContext.getSubcontext(SessionContext.class);
+            if (sessionContext != null) {
+                final IdPSession idpSession = sessionContext.getIdPSession();
+                if (idpSession != null) {
+                    return idpSession.getPrincipalName();
+                }
+            }
+        }
+        
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateCredentials.java b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateCredentials.java
index d3feb09bd..9bd6d9866 100644
--- a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateCredentials.java
+++ b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateCredentials.java
@@ -29,6 +29,8 @@ import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 
+import com.google.common.net.UrlEscapers;
+
 import net.shibboleth.idp.authn.AccountLockoutManager;
 import net.shibboleth.idp.authn.AuthenticationResult;
 import net.shibboleth.idp.authn.AuthnAuditFields;
@@ -42,7 +44,11 @@ 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.net.CookieManager;
 import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
 
 /**
  * An action that processes a list of {@link CredentialValidator} objects to produce an {@link AuthenticationResult}.
@@ -284,19 +290,91 @@ public class ValidateCredentials extends AbstractAuditingValidationAction implem
      */
     public static class UsernamePasswordCleanupHook implements Consumer<ProfileRequestContext> {
 
+        /** Class logger. */
+        @Nonnull private final Logger log = LoggerFactory.getLogger(UsernamePasswordCleanupHook.class);
+        
+        /** Username cookie name. */
+        @Nullable @NotEmpty private String cookieName;
+
+        /** Optional cookie manager to use. */
+        @Nullable private CookieManager cookieManager;
+
+        /** Optional data sealer to use. */
+        @Nullable private DataSealer dataSealer;
+
+        /**
+         * Set cookie name to use for cached username.
+         * 
+         * @param name cookie name
+         * 
+         * @since 5.1.0
+         */
+        public void setCookieName(@Nullable final String name) {
+            cookieName = StringSupport.trimOrNull(name);
+        }
+        
+        /**
+         * Sets optional {@link CookieManager} to use.
+         * 
+         * @param manager cookie manager
+         * 
+         * @since 5.1.0
+         */
+        public void setCookieManager(@Nullable final CookieManager manager) {
+            cookieManager = manager;
+        }
+
+        /**
+         * Sets optional {@link DataSealer} to use.
+         * 
+         * @param sealer data sealer
+         * 
+         * @since 5.1.0
+         */
+        public void setDataSealer(@Nullable final DataSealer sealer) {
+            dataSealer = sealer;
+        }
+        
+// Checkstyle: CyclomaticComplexity OFF
         /** {@inheritDoc} */
         public void accept(@Nullable final ProfileRequestContext input) {
-            if (input != null) {
-                final AuthenticationContext authnCtx = input.getSubcontext(AuthenticationContext.class);
-                if (authnCtx != null) {
-                    final UsernamePasswordContext upCtx = authnCtx.getSubcontext(UsernamePasswordContext.class);
-                    if (upCtx != null) {
-                        upCtx.setPassword(null);
-                        authnCtx.removeSubcontext(upCtx);
+            
+            final AuthenticationContext authnCtx =
+                    input != null ? input.getSubcontext(AuthenticationContext.class) : null;
+            if (authnCtx == null) {
+                return;
+            }
+
+            final UsernamePasswordContext upCtx = authnCtx.getSubcontext(UsernamePasswordContext.class);
+            if (upCtx == null) {
+                return;
+            }
+            
+            final String localCookieName = cookieName;
+            if (authnCtx.isResultCacheable()) {
+                if (cookieManager != null && dataSealer != null && localCookieName != null) {
+                    String wrapped = upCtx.getUsername();
+                    if (wrapped != null) {
+                        try {
+                            assert dataSealer != null;
+                            wrapped = dataSealer.wrap(wrapped);
+                            assert cookieManager != null;
+                            cookieManager.addCookie(localCookieName,
+                                    UrlEscapers.urlFormParameterEscaper().escape(wrapped));
+                        } catch (final DataSealerException e) {
+                            wrapped = null;
+                            log.warn("Error sealing username cookie", e);
+                        }
                     }
                 }
+            } else if (cookieManager != null && localCookieName != null) {
+                cookieManager.unsetCookie(localCookieName);
             }
+
+            upCtx.setPassword(null);
+            authnCtx.removeSubcontext(upCtx);
         }
     }
+// Checkstyle: CyclomaticComplexity ON
 
 }
\ No newline at end of file
diff --git a/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-beans.xml b/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-beans.xml
index f399f51d1..749a03a7a 100644
--- a/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-beans.xml
+++ b/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-beans.xml
@@ -43,6 +43,15 @@
         class="net.shibboleth.idp.authn.impl.ExtractUsernamePasswordFromBasicAuth" scope="prototype"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
     
+    <bean id="PrePopulateUsername"
+        class="net.shibboleth.idp.authn.impl.PrePopulateUsername" scope="prototype"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:dataSealer="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.DataSealer')}"
+        p:cookieManager="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.PersistentCookieManager')}"
+        p:cookieName="%{idp.authn.usernameCookieName:}"
+        p:usernameFieldName="#{getObject('shibboleth.authn.Password.UsernameFieldName') ?: '%{idp.authn.Password.usernameFieldName:j_username}'.trim()}"
+        p:checkSession="%{idp.authn.usernameFromSession:false}" />
+    
     <bean id="ExtractUsernamePasswordFromFormRequest"
         class="net.shibboleth.idp.authn.impl.ExtractUsernamePasswordFromFormRequest" scope="prototype"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
@@ -54,7 +63,10 @@
         class="net.shibboleth.idp.authn.impl.PopulateSubjectCanonicalizationContext" scope="prototype"
         p:availableFlows-ref="shibboleth.PostLoginSubjectCanonicalizationFlows" />
     
-    <bean id="DefaultCleanupHook" class="net.shibboleth.idp.authn.impl.ValidateCredentials.UsernamePasswordCleanupHook" />
+    <bean id="DefaultCleanupHook" class="net.shibboleth.idp.authn.impl.ValidateCredentials.UsernamePasswordCleanupHook"
+        p:dataSealer="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.DataSealer')}"
+        p:cookieManager="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.PersistentCookieManager')}"
+        p:cookieName="%{idp.authn.usernameCookieName:}" />
     
     <!-- New action bean that uses CredentialValidator chains. -->
     <bean id="ValidateCredentials"
diff --git a/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-flow.xml b/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-flow.xml
index c5b103849..757000afc 100644
--- a/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-flow.xml
+++ b/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/authn/password-authn-flow.xml
@@ -19,8 +19,16 @@
         <!-- Fall through to a different flow if basic-auth extract fails on a passive or non-browser request. -->
         <transition on="#{ opensamlProfileRequestContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() || !opensamlProfileRequestContext.isBrowserProfile() }" to="ReselectFlow" />
         
-        <transition on="NoCredentials" to="DisplayUsernamePasswordPage" />
-        <transition on="InvalidCredentials" to="DisplayUsernamePasswordPage" />
+        <transition on="NoCredentials" to="PrePopulateUsername" />
+        <transition on="InvalidCredentials" to="PrePopulateUsername" />
+    </action-state>
+    
+    <action-state id="PrePopulateUsername">
+        <evaluate expression="PrePopulateUsername" />
+        <evaluate expression="'proceed'" />
+        
+        <!-- This action is largely advisory/optimization so just advance to the view no matter what. -->
+        <transition to="DisplayUsernamePasswordPage" />
     </action-state>
     
     <view-state id="DisplayUsernamePasswordPage" view="login">
diff --git a/idp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/authn/authn.properties b/idp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/authn/authn.properties
index 405c52288..797c32687 100644
--- a/idp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/authn/authn.properties
+++ b/idp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/authn/authn.properties
@@ -27,6 +27,12 @@
 # Login flow audit logging (defaults false for log compatibility)
 #idp.authn.audit.enabled = false
 
+# Uncomment to cache username in cookie (e.g. __Host_shib_idp_username)
+#idp.authn.usernameCookieName =
+# Set true to populate username in login forms from session as a last resort
+#idp.authn.usernameFromSession = false
+
+
 # Revocation (administrative logout)
 #idp.authn.revocation = false
 #idp.authn.revocation.lifetime = %{idp.authn.defaultAuthnLifetime:PT12H}

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


More information about the commits mailing list