[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