[java-idp-plugin-duo] branch dev/JDUO-80 updated: JDUO-80 - More WIP, closer to a working model.
Scott Cantor
cantor.2 at osu.edu
Thu Dec 21 20:21:22 UTC 2023
This is an automated email from the git hooks/post-receive script.
scantor pushed a commit to branch dev/JDUO-80
in repository java-idp-plugin-duo.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-duo.git;a=commit;h=697810c02e48caf3dde44ec15a57df53f586bcba
The following commit(s) were added to refs/heads/dev/JDUO-80 by this push:
new 697810c0 JDUO-80 - More WIP, closer to a working model.
697810c0 is described below
commit 697810c02e48caf3dde44ec15a57df53f586bcba
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Thu Dec 21 15:21:19 2023 -0500
JDUO-80 - More WIP, closer to a working model.
---
.../plugin/authn/duo/DefaultDuoCleanupHook.java | 152 +++++++++
.../authn/duo/DefaultDuoOIDCIntegration.java | 27 +-
.../idp/plugin/authn/duo/DuoOIDCIntegration.java | 16 +-
.../plugin/authn/duo/SimpleDuoOIDCIntegration.java | 33 +-
.../authn/duo/context/DuoPasswordlessContext.java | 12 +-
.../duo/impl/CheckPasswordlessEnrollment.java | 346 +++++++++++++++++++++
.../duo/impl/ExtractPasswordlessUsername.java | 230 --------------
.../duo/impl/PopulateDuoAuthenticationContext.java | 204 ++++++++----
.../impl/ValidateDuoTokenAuthenticationResult.java | 28 +-
.../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml | 45 ++-
.../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml | 41 ++-
.../nimbus/views/{username.vm => passwordless.vm} | 11 +-
12 files changed, 773 insertions(+), 372 deletions(-)
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoCleanupHook.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoCleanupHook.java
new file mode 100644
index 00000000..365147a8
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoCleanupHook.java
@@ -0,0 +1,152 @@
+/*
+ * 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.function.Consumer;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.google.common.net.UrlEscapers;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+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;
+
+/**
+ * A default cleanup hook for the DuoOIDC flow that handles both standard
+ * and passwordless scenarios, with configuration flexibility for the deployer.
+ *
+ * @since 2.1.0
+ */
+public class DefaultDuoCleanupHook extends AbstractInitializableComponent implements Consumer<ProfileRequestContext> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultDuoCleanupHook.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;
+
+ /** Whether to remove a {@link DuoPasswordlessContext} if present. */
+ private boolean removePasswordlessContext;
+
+ /** Constructor. */
+ public DefaultDuoCleanupHook() {
+ removePasswordlessContext = true;
+ }
+
+ /**
+ * 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 to remove a {@link DuoPasswordlessContext} if present.
+ *
+ * @param flag flag to set
+ */
+ public void setRemovePasswordlessContext(final boolean flag) {
+ checkSetterPreconditions();
+
+ removePasswordlessContext = flag;
+ }
+
+ /** {@inheritDoc} */
+ public void accept(@Nullable final ProfileRequestContext input) {
+ checkComponentActive();
+
+ final AuthenticationContext authnCtx = input != null ? input.getSubcontext(AuthenticationContext.class) : null;
+ if (authnCtx == null) {
+ return;
+ }
+
+ final DuoOIDCAuthenticationContext duoCtx = authnCtx.getSubcontext(DuoOIDCAuthenticationContext.class);
+ if (duoCtx != null) {
+ duoCtx.removeFromParent();
+ }
+
+ final DuoPasswordlessContext passwordlessCtx = authnCtx.getSubcontext(DuoPasswordlessContext.class);
+ if (passwordlessCtx != null) {
+ if (removePasswordlessContext) {
+ passwordlessCtx.removeFromParent();
+ }
+
+ final String localCookieName = cookieName;
+ if (authnCtx.isResultCacheable()) {
+ if (cookieManager != null && dataSealer != null && localCookieName != null) {
+ String wrapped = passwordlessCtx.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);
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
index 41b7503d..4f189459 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
@@ -54,7 +54,10 @@ public final class DefaultDuoOIDCIntegration
/** Class logger. */
@Nonnull private final Logger log = LoggerFactory.getLogger(DefaultDuoOIDCIntegration.class);
-
+
+ /** Passwordless indicator. */
+ @GuardedBy("this") private boolean passwordless;
+
/** API host. */
@GuardedBy("this") @NonnullAfterInit @NotEmpty private String apiHost;
@@ -84,12 +87,32 @@ public final class DefaultDuoOIDCIntegration
/** Container for supported principals. */
@GuardedBy("this") @Nonnull private final Subject supportedPrincipals;
-
+
/** Constructor. */
public DefaultDuoOIDCIntegration() {
supportedPrincipals = new Subject();
allowedOrigins = CollectionSupport.emptySet();
}
+
+ /**
+ * Sets whether this integration is suitable for use as a single factor.
+ *
+ * <p>Defaults to false.</p>
+ *
+ * @param flag flag to set
+ *
+ * @since 2.1.0
+ */
+ public synchronized void setPasswordless(final boolean flag) {
+ checkSetterPreconditions();
+ passwordless = flag;
+ }
+
+ /** {@inheritDoc} */
+ public synchronized boolean isPasswordless() {
+ checkComponentActive();
+ return passwordless;
+ }
/**
* Set the origins that are allowed to form the scheme, host, and port part of a computed redirect_uri.
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
index 77e4e3b9..9d49885e 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
@@ -78,7 +78,17 @@ public interface DuoOIDCIntegration extends PrincipalSupportingComponent {
*/
@Nonnull @NotEmpty String getTokenEndpoint();
-
-
+ /**
+ * Gets whether the integration is suitable for use as a passwordless single factor.
+ *
+ * <p>Defaults to false.</p>
+ *
+ * @return true iff the integration limits methods to passwordless
+ *
+ * @since 2.1.0
+ */
+ default boolean isPasswordless() {
+ return false;
+ }
-}
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java
index 29067330..29add449 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java
@@ -36,11 +36,16 @@ import net.shibboleth.shared.primitive.StringSupport;
/**
- * A data wrapper for use with Duo OIDC integrations which does not support redirectURI generation.
+ * A data wrapper for use with Duo OIDC integrations which does not support redirectURI generation.
+ *
+ * <p>This class was replaced by the {@link DefaultDuoOIDCIntegration} subclass.</p>
+ *
+ * @deprecated
*/
@ThreadSafe
+ at Deprecated(forRemoval=true, since="2.1.0")
public final class SimpleDuoOIDCIntegration
- extends AbstractInitializableComponent implements DuoOIDCIntegration{
+ extends AbstractInitializableComponent implements DuoOIDCIntegration {
/** API host. */
@GuardedBy("this") @NonnullAfterInit @NotEmpty private String apiHost;
@@ -84,8 +89,7 @@ public final class SimpleDuoOIDCIntegration
* @param host API host
*/
public synchronized void setAPIHost(@Nonnull @NotEmpty final String host) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
apiHost = Constraint.isNotNull(StringSupport.trimOrNull(host), "API host cannot be null or empty");
}
@@ -103,8 +107,7 @@ public final class SimpleDuoOIDCIntegration
* @param endpoint the endpoint.
*/
public synchronized void setHealthCheckEndpoint(@Nonnull @NotEmpty final String endpoint) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
healthEndpoint = Constraint.isNotNull(StringSupport.trimOrNull(endpoint),
"Health check endpoint cannot be null or empty");
@@ -123,8 +126,7 @@ public final class SimpleDuoOIDCIntegration
* @param endpoint the endpoint.
*/
public synchronized void setAuthorizeEndpoint(@Nonnull @NotEmpty final String endpoint) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
authorizeEndpoint = Constraint.isNotNull(StringSupport.trimOrNull(endpoint),
"Authorize endpoint cannot be null or empty");
@@ -143,8 +145,7 @@ public final class SimpleDuoOIDCIntegration
* @param endpoint the endpoint.
*/
public synchronized void setTokenEndpoint(@Nonnull @NotEmpty final String endpoint) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
tokenEndpoint = Constraint.isNotNull(StringSupport.trimOrNull(endpoint),
"Token endpoint cannot be null or empty");
@@ -161,8 +162,7 @@ public final class SimpleDuoOIDCIntegration
* @param url the url.
*/
public synchronized void setRedirectURI(@Nonnull final String url) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
redirectURI = Constraint.isNotNull(StringSupport.trimOrNull(url), "Redirect URI cannot be null or empty");;
}
@@ -174,8 +174,7 @@ public final class SimpleDuoOIDCIntegration
* @param id the client identifier.
*/
public synchronized void setClientId(@Nonnull @NotEmpty final String id) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
clientId = Constraint.isNotNull(StringSupport.trimOrNull(id), "ClientID cannot be null or empty");
}
@@ -193,8 +192,7 @@ public final class SimpleDuoOIDCIntegration
* @param key secret key
*/
public synchronized void setSecretKey(@Nonnull @NotEmpty final String key) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
secretKey = Constraint.isNotNull(StringSupport.trimOrNull(key), "Secret key cannot be null or empty");
}
@@ -225,8 +223,7 @@ public final class SimpleDuoOIDCIntegration
*/
public synchronized <T extends Principal> void setSupportedPrincipals(
@Nullable @NonnullElements final Collection<T> principals) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
supportedPrincipals.getPrincipals().clear();
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
similarity index 87%
rename from idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
rename to idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
index 9eab6c28..26627774 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
@@ -12,7 +12,9 @@
* limitations under the License.
*/
-package net.shibboleth.idp.authn.duo.context;
+package net.shibboleth.idp.plugin.authn.duo.context;
+
+import java.util.Objects;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -53,12 +55,18 @@ public final class DuoPasswordlessContext extends BaseContext {
/**
* Set the username.
*
+ * <p>When changing the context's existing value, the context will clear the
+ * {@link #isEnrolled()} setting to false.</p>
+ *
* @param name username
*
* @return this context
*/
@Nonnull public DuoPasswordlessContext setUsername(@Nullable final String name) {
- username = name;
+ if (!Objects.equals(name, username)) {
+ username = name;
+ enrolled = false;
+ }
return this;
}
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
new file mode 100644
index 00000000..983f5620
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
@@ -0,0 +1,346 @@
+/*
+ * 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 java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.google.common.net.UrlEscapers;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.authn.AbstractExtractionAction;
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
+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.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 {@link DuoPasswordlessContext}, either from a form
+ * submission, a cookie, or an existing session, and perform a check for suitability for
+ * that user to conduct passwordless use of Duo, typically based on device enrollments.
+ *
+ * <p>If no username is found, then {@link AuthnEventIds#UNKNOWN_USERNAME} is signaled.</p>
+ *
+ * <p>If the username in the context is "changed" from its existing state, then the condition
+ * for passwordless usage is executed and the result stored into the context. A failed check
+ * results in the @event {@link AuthnEventIds#REQUEST_UNSUPPORTED} event.</p>
+ *
+ * <p>The action also processes a signal to avoid caching the result of authentication (and also
+ * avoid caching the username entered) for shared machines.</p>
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
+ * @event {@link AuthnEventIds#REQUEST_UNSUPPORTED}
+ * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found
+ * and {@link DuoPasswordlessContext#setEnrolled(boolean)} is called with the relevant value
+ *
+ * @since 2.1.0
+ */
+public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(CheckPasswordlessEnrollment.class);
+
+ /** Strategy used to locate the {@link DuoPasswordlessContext} to operate on. */
+ @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
+
+ /** Condition indicating passwordless use is valid. */
+ @Nonnull private BiPredicate<ProfileRequestContext,String> passwordlessCondition;
+
+ /** Form parameter name to carry username. */
+ @Nonnull @NotEmpty private String usernameFieldName;
+
+ /** Parameter name for SSO bypass. */
+ @Nonnull @NotEmpty private String ssoBypassFieldName;
+
+ /** 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 DuoPasswordlessContext passwordlessContext;
+
+ /** Constructor.*/
+ public CheckPasswordlessEnrollment() {
+ duoPasswordlessContextLookupStrategy =
+ new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
+ new ChildContextLookup<>(AuthenticationContext.class));
+
+ // TODO: Real default once implemented.
+ passwordlessCondition = (a,b) -> { return false; };
+
+ usernameFieldName = "j_username";
+ ssoBypassFieldName = "donotcache";
+ }
+
+ /**
+ * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setDuoContextCreationStrategy(
+ @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
+ checkSetterPreconditions();
+
+ duoPasswordlessContextLookupStrategy =
+ Constraint.isNotNull(strategy, "DuoPasswordlessContext lookup strategy cannot be null");
+ }
+
+ /**
+ * Set a {@link BiPredicate} to run to determine whether to proceed with passwordless Duo.
+ *
+ * @param condition condition to set
+ */
+ public void setPasswordlessCondition(@Nonnull final BiPredicate<ProfileRequestContext,String> condition) {
+ checkSetterPreconditions();
+
+ passwordlessCondition = Constraint.isNotNull(condition, "Condition 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 the SSO bypass parameter name.
+ *
+ * @param fieldName the SSO bypass parameter name
+ */
+ public void setSSOBypassFieldName(@Nonnull @NotEmpty final String fieldName) {
+ checkSetterPreconditions();
+
+ ssoBypassFieldName = Constraint.isNotNull(
+ StringSupport.trimOrNull(fieldName), "SSO Bypass 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;
+ }
+
+ passwordlessContext = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
+ return passwordlessContext != null;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final AuthenticationContext authenticationContext) {
+
+ String username = getUsernameFromForm(authenticationContext);
+ if (username != null) {
+ if (!username.equals(passwordlessContext.getUsername()) ) {
+ log.debug("{} Populating username '{}' from form submission into Duo passwordless context",
+ getLogPrefix(), username);
+ passwordlessContext.setUsername(username);
+ }
+ } else {
+ username = getUsernameFromCookie(profileRequestContext);
+ if (username != null) {
+ if (!username.equals(passwordlessContext.getUsername()) ) {
+ log.debug("{} Populating cached username '{}' from cookie into Duo passwordless context",
+ getLogPrefix(), username);
+ passwordlessContext.setUsername(username);
+ }
+ } else {
+ username = getUsernameFromSession(profileRequestContext);
+ if (username != null && !username.equals(passwordlessContext.getUsername())) {
+ log.debug("{} Populating username '{}' from session into Duo passwordless context", getLogPrefix(),
+ username);
+ passwordlessContext.setUsername(username);
+ }
+ }
+ }
+
+ if (passwordlessContext.getUsername() == null) {
+ passwordlessContext.setEnrolled(false);
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
+ return;
+ }
+
+ if (!passwordlessContext.isEnrolled()) {
+ passwordlessContext.setEnrolled(
+ passwordlessCondition.test(profileRequestContext, passwordlessContext.getUsername()));
+ }
+
+ log.debug("{} Username '{}' found to be {} of passwordless attempt", getLogPrefix(),
+ passwordlessContext.getUsername(), passwordlessContext.isEnrolled() ? "capable" : "incapable");
+ if (!passwordlessContext.isEnrolled()) {
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+ }
+ }
+
+ /**
+ * 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) {
+ final String donotcache = request.getParameter(ssoBypassFieldName);
+ if (donotcache != null && "1".equals(donotcache)) {
+ log.debug("{} Recording do-not-cache instruction in authentication context", getLogPrefix());
+ authenticationContext.setResultCacheable(false);
+ } else {
+ authenticationContext.setResultCacheable(true);
+ }
+
+ 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 = cookieManager.getCookieValue(cookieName, null);
+ if (cookie != null) {
+ try {
+ assert dataSealer != null;
+ return dataSealer.unwrap(UrlEscapers.urlFormParameterEscaper().escape(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
+ *
+ * @return username from existing session, or null
+ */
+ @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (checkSession) {
+ 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-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
deleted file mode 100644
index 1fae5dfc..00000000
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
+++ /dev/null
@@ -1,230 +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.Function;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-
-import org.opensaml.messaging.context.navigate.ChildContextLookup;
-import org.opensaml.profile.action.ActionSupport;
-import org.opensaml.profile.action.EventIds;
-import org.opensaml.profile.context.ProfileRequestContext;
-import org.slf4j.Logger;
-
-import com.google.common.net.UrlEscapers;
-
-import jakarta.servlet.http.HttpServletRequest;
-import net.shibboleth.idp.authn.AbstractExtractionAction;
-import net.shibboleth.idp.authn.AuthnEventIds;
-import net.shibboleth.idp.authn.context.AuthenticationContext;
-import net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext;
-import net.shibboleth.idp.session.IdPSession;
-import net.shibboleth.idp.session.context.SessionContext;
-import net.shibboleth.shared.logic.Constraint;
-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 to populate a username into a {@link DuoPasswordlessContext}, either from a form
- * submission, a cookie, or an existing session.
- *
- * <p>If no username is found, then {@link AuthnEventIds#UNKNOWN_USERNAME} is signaled.</p>
- *
- * @event {@link EventIds#PROCEED_EVENT_ID}
- * @event {@link EventIds#INVALID_PROFILE_CTX}
- * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
- * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found.
- *
- * @since 2.1.0
- */
-public class ExtractPasswordlessUsername extends AbstractExtractionAction {
-
- /** Cookie name to cache username. */
- @Nonnull public static final String COOKIE_NAME = "_shibidp_duo_username";
-
- /** Class logger. */
- @Nonnull private final Logger log = LoggerFactory.getLogger(ExtractPasswordlessUsername.class);
-
- /** Strategy used to locate the {@link DuoPasswordlessContext} to populate. */
- @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
-
- /** Form parameter name to carry username. */
- @Nonnull private String usernameFieldName = "j_username";
-
- /** Optional cookie manager to use. */
- @Nullable private CookieManager cookieManager;
-
- /** Optional data sealer to use. */
- @Nullable private DataSealer dataSealer;
-
- /** Constructor.*/
- public ExtractPasswordlessUsername() {
- duoPasswordlessContextLookupStrategy =
- new ChildContextLookup<>(DuoPasswordlessContext.class, true).compose(
- new ChildContextLookup<>(AuthenticationContext.class));
- }
-
- /**
- * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
- *
- * @param strategy lookup strategy
- */
- public void setDuoContextCreationStrategy(
- @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
- checkSetterPreconditions();
-
- duoPasswordlessContextLookupStrategy =
- 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) {
- usernameFieldName = Constraint.isNotNull(StringSupport.trimOrNull(name) ,
- "Username form field name cannot be null or empty");
- }
-
- /**
- * 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;
- }
-
- /** {@inheritDoc} */
- @Override
- protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
- @Nonnull final AuthenticationContext authenticationContext) {
-
- final DuoPasswordlessContext context = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
- if (context == null) {
- log.error("{} Error locating DuoPasswordlessContext", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
- return;
- }
-
- String username = getUsernameFromForm();
- if (username != null) {
- log.debug("{} Populating username '{}' from form submission into Duo passwordless context", getLogPrefix(),
- username);
- context.setUsername(username);
- return;
- }
-
- username = getUsernameFromCookie(profileRequestContext);
- if (username != null) {
- log.debug("{} Populating cached username '{}' from cookie into Duo passwordless context", getLogPrefix(),
- username);
- context.setUsername(username);
- return;
- }
-
- username = getUsernameFromSession(profileRequestContext);
- if (username != null) {
- log.debug("{} Populating username '{}' from session into Duo passwordless context", getLogPrefix(),
- username);
- context.setUsername(username);
- return;
- }
-
- if (context.getUsername() == null) {
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
- }
- }
-
- /**
- * Gets the username from a form submission.
- *
- * @return submitted username, after applying any configured transforms
- */
- @Nullable private String getUsernameFromForm() {
-
- 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) {
- final String cookie = cookieManager.getCookieValue(COOKIE_NAME, null);
- if (cookie != null) {
- try {
- assert dataSealer != null;
- return dataSealer.unwrap(UrlEscapers.urlFormParameterEscaper().escape(cookie));
- } catch (final DataSealerException e) {
- log.warn("{} Unable to unwrap sealed username cookie", getLogPrefix(), e);
- assert cookieManager != null;
- cookieManager.unsetCookie(COOKIE_NAME);
- }
- }
- }
-
- return null;
- }
-
- /**
- * Gets the username from an existing {@link IdPSession}, if any.
- *
- * @param profileRequestContext profile request context
- *
- * @return username from existing session, or null
- */
- @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext) {
- 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-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java
index 4447da1f..917e9ef4 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java
@@ -36,6 +36,7 @@ import net.shibboleth.idp.plugin.authn.duo.DuoOIDCClientRegistry;
import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
import net.shibboleth.idp.plugin.authn.duo.DynamicDuoOIDCIntegration;
import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
import net.shibboleth.idp.session.context.navigate.CanonicalUsernameLookupStrategy;
import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
import net.shibboleth.shared.component.ComponentInitializationException;
@@ -46,7 +47,10 @@ import net.shibboleth.shared.primitive.LoggerFactory;
/**
* An action to create (or lookup) and populate the {@link DuoOIDCAuthenticationContext}
- * with the username, chosen {@link DuoOIDCIntegration}, and {@link DuoOIDCClient} appropriate for this request.
+ * with the username, chosen {@link DuoOIDCIntegration}, and {@link DuoOIDCClient} appropriate for this request.
+ *
+ * <p>Operates in 2 modes, one for passwordless (indicated by presence of a {@link DuoPasswordlessContext}, or
+ * a standard mode. The difference is in how the username and integration to use are derived.</p>
*
* <p>Determines the usable redirect_uri, either from one registered, or computed from the
* HTTP request. Is set once, before the client is constructed, for every client. If however, the client supports
@@ -54,10 +58,10 @@ import net.shibboleth.shared.primitive.LoggerFactory;
*
* <p>Adds the nonce part of the state parameter for matching on callback from the 2FA check.</p>
*
- * @event {@link org.opensaml.profile.action.EventIds#PROCEED_EVENT_ID}
- * @event {@link org.opensaml.profile.action.EventIds#INVALID_PROFILE_CTX}
- * @event {@link net.shibboleth.idp.authn.AuthnEventIds#NO_CREDENTIALS}
- * @event {@link net.shibboleth.idp.authn.AuthnEventIds#AUTHN_EXCEPTION}
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#NO_CREDENTIALS}
+ * @event {@link AuthnEventIds#AUTHN_EXCEPTION}
* @post See above.
*/
public class PopulateDuoAuthenticationContext extends AbstractAuthenticationAction {
@@ -68,28 +72,38 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
/** Strategy used to locate or create the {@link DuoOIDCAuthenticationContext} to populate. */
@Nonnull private Function<ProfileRequestContext,DuoOIDCAuthenticationContext> duoAuthContextCreationStrategy;
+ /** Strategy used to locate a {@link DuoPasswordlessContext} if present. */
+ @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> passwordlessContextLookupStrategy;
+
/** Lookup strategy for username to match against Duo identity. */
@Nonnull private Function<ProfileRequestContext, String> usernameLookupStrategy;
/** Lookup strategy for Duo integration. */
- @Nonnull private Function<ProfileRequestContext, DuoOIDCIntegration> duoIntegrationLookupStrategy;
+ @Nonnull private Function<ProfileRequestContext, DuoOIDCIntegration> standardDuoIntegrationLookupStrategy;
+
+ /** Lookup strategy for Duo integration for passwordless use. */
+ @Nonnull private Function<ProfileRequestContext, DuoOIDCIntegration> passwordlessDuoIntegrationLookupStrategy;
/** Strategy used to compute the redirectURI from the given Duo integration if supported.*/
- @Nullable
- private BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> redirectURICreationStrategy;
+ @Nullable private BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> redirectURICreationStrategy;
/** The registry for locating the DuoClient for the established integration.*/
@NonnullAfterInit private DuoOIDCClientRegistry clientRegistry;
-
+
/** Constructor.*/
public PopulateDuoAuthenticationContext() {
//default creates duo authentication context under authentication context.
duoAuthContextCreationStrategy =
- new ChildContextLookup<>(DuoOIDCAuthenticationContext.class, true).
- compose(new ChildContextLookup<>(AuthenticationContext.class));
+ new ChildContextLookup<>(DuoOIDCAuthenticationContext.class, true).compose(
+ new ChildContextLookup<>(AuthenticationContext.class));
+
+ passwordlessContextLookupStrategy =
+ new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
+ new ChildContextLookup<>(AuthenticationContext.class));
usernameLookupStrategy = new CanonicalUsernameLookupStrategy();
- duoIntegrationLookupStrategy = FunctionSupport.constant(null);
+ standardDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
+ passwordlessDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
}
/**
@@ -98,8 +112,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
* @param duoRegistry the registry
*/
public void setClientRegistry(@Nonnull final DuoOIDCClientRegistry duoRegistry) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
clientRegistry = Constraint.isNotNull(duoRegistry,"DuoClient registry can not be null");
}
@@ -111,8 +124,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
*/
public void setUsernameLookupStrategy(
@Nonnull final Function<ProfileRequestContext, String> strategy) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
usernameLookupStrategy = Constraint.isNotNull(strategy, "Username lookup strategy cannot be null");
}
@@ -125,8 +137,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
*/
public void setRedirectURICreationStrategy(
@Nonnull final BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> strategy) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
redirectURICreationStrategy = Constraint.isNotNull(strategy, "RedirectURI"
+ " creation strategy cannot be null");
@@ -139,8 +150,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
*/
public void setDuoContextCreationStrategy(
@Nonnull final Function<ProfileRequestContext,DuoOIDCAuthenticationContext> strategy) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
duoAuthContextCreationStrategy = Constraint.isNotNull(strategy, "DuoAuthenticationContext"
+ " creation strategy cannot be null");
@@ -151,14 +161,26 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
*
* @param strategy lookup strategy
*/
- public void setDuoIntegrationLookupStrategy(
+ public void setStandardDuoIntegrationLookupStrategy(
@Nonnull final Function<ProfileRequestContext, DuoOIDCIntegration> strategy) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
- duoIntegrationLookupStrategy = Constraint.isNotNull(strategy, "DuoIntegration lookup strategy cannot be null");
+ standardDuoIntegrationLookupStrategy =
+ Constraint.isNotNull(strategy, "Standard DuoIntegration lookup strategy cannot be null");
}
-
+
+ /**
+ * Set DuoIntegration lookup strategy to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setPasswordlessDuoIntegrationLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, DuoOIDCIntegration> strategy) {
+ checkSetterPreconditions();
+ passwordlessDuoIntegrationLookupStrategy =
+ Constraint.isNotNull(strategy, "Passwordless DuoIntegration lookup strategy cannot be null");
+ }
+
/** {@inheritDoc} */
@Override protected void doInitialize() throws ComponentInitializationException {
super.doInitialize();
@@ -173,62 +195,134 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
@Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
@Nonnull final AuthenticationContext authenticationContext) {
-
- final DuoOIDCAuthenticationContext context = duoAuthContextCreationStrategy.apply(profileRequestContext);
- if (context == null) {
- log.error("{} Error creating DuoAuthenticationContext", getLogPrefix());
+ final HttpServletRequest request = getHttpServletRequest();
+ if (request == null) {
+ log.warn("{} Profile action does not contain an HttpServletRequest", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
return;
}
-
- final DuoOIDCIntegration duoIntegration = duoIntegrationLookupStrategy.apply(profileRequestContext);
- if (duoIntegration == null) {
- log.warn("{} No DuoIntegration returned by lookup strategy", getLogPrefix());
+
+ final DuoOIDCAuthenticationContext duoContext = duoAuthContextCreationStrategy.apply(profileRequestContext);
+ if (duoContext == null) {
+ log.error("{} Error creating DuoAuthenticationContext", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
return;
}
- context.setIntegration(duoIntegration);
-
- final HttpServletRequest request = getHttpServletRequest();
- if (request == null) {
- log.warn("{} Profile action does not contain an HttpServletRequest", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
- return;
- }
- final String username = usernameLookupStrategy.apply(profileRequestContext);
- if (username == null) {
- log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
- return;
+ final DuoPasswordlessContext passwordlessContext =
+ passwordlessContextLookupStrategy.apply(profileRequestContext);
+ if (passwordlessContext != null) {
+ if (!doPasswordless(profileRequestContext, duoContext, passwordlessContext)) {
+ duoContext.removeFromParent();
+ return;
+ }
+ } else {
+ if (!doStandard(profileRequestContext, duoContext)) {
+ duoContext.removeFromParent();
+ return;
+ }
}
- context.setUsername(username);
+ final DuoOIDCIntegration duoIntegration = duoContext.getIntegration();
+ assert duoIntegration != null;
+
// Generate state, stash in the context for checking on return.
final String nonce = DuoSupport.generateNonce(32);
// Store only the nonce component as the request state. The SWF key is added by the controller
// And included in the authorization request to Duo.
- context.setRequestState(nonce);
-
+ duoContext.setRequestState(nonce);
+
try {
- computeAndStoreRedirectURIIfSupported(duoIntegration, request, context);
+ computeAndStoreRedirectURIIfSupported(duoIntegration, request, duoContext);
//Configure the Duo client for the established integration
final DuoOIDCClient client = clientRegistry.getClientOrCreate(duoIntegration);
- context.setClient(client);
+ duoContext.setClient(client);
} catch (final DuoException e) {
log.warn("{} Unable to establish a Duo Client for the given integration", getLogPrefix(),e);
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.AUTHN_EXCEPTION);
+ duoContext.removeFromParent();
return;
}
- log.debug("Created Duo authentication context for '{}'",username);
+ log.debug("Created Duo authentication context for '{}'", duoContext.getUsername());
+ }
+
+ /**
+ * Perform standard context creation and lookups.
+ *
+ * @param profileRequestContext profile request context
+ * @param duoContext newly created Duo context
+ * @param passwordlessContext Duo passwordless context
+ *
+ * @return true iff processing should continue
+ */
+ private boolean doPasswordless(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final DuoOIDCAuthenticationContext duoContext,
+ @Nonnull final DuoPasswordlessContext passwordlessContext) {
+
+ if (passwordlessContext.getUsername() == null) {
+ log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+ return false;
+ }
+ duoContext.setUsername(passwordlessContext.getUsername());
+
+ if (!passwordlessContext.isEnrolled()) {
+ log.warn("{} Context indicates user '{}' is not eligible for passwordless, proceeding anyway",
+ getLogPrefix(), duoContext.getUsername());
+ }
+
+ final DuoOIDCIntegration duoIntegration = passwordlessDuoIntegrationLookupStrategy.apply(profileRequestContext);
+ if (duoIntegration == null) {
+ log.warn("{} No DuoIntegration returned by lookup strategy", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ } else if (!duoIntegration.isPasswordless()) {
+ log.warn("{} DuoIntegration returned by lookup strategy was not passwordless", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ duoContext.setIntegration(duoIntegration);
+
+ return true;
+ }
+
+ /**
+ * Perform standard context creation and lookups.
+ *
+ * @param profileRequestContext profile request context
+ * @param duoContext newly created Duo context
+ *
+ * @return true iff processing should continue
+ */
+ private boolean doStandard(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final DuoOIDCAuthenticationContext duoContext) {
+
+ final DuoOIDCIntegration duoIntegration = standardDuoIntegrationLookupStrategy.apply(profileRequestContext);
+ if (duoIntegration == null) {
+ log.warn("{} No DuoIntegration returned by lookup strategy", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ duoContext.setIntegration(duoIntegration);
+
+ final String username = usernameLookupStrategy.apply(profileRequestContext);
+ if (username == null) {
+ log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+ return false;
+ }
+ duoContext.setUsername(username);
+
+ return true;
}
/**
- * <p>For {@link DynamicDuoOIDCIntegration DynamicDuoOIDCIntegrations}, apply the redirect_uri creation
- * strategy to compute a redirect_uri to use.</p>
+ * For {@link DynamicDuoOIDCIntegration DynamicDuoOIDCIntegrations}, apply the redirect_uri creation
+ * strategy to compute a redirect_uri to use.
*
* <p>The redirect_uri is computed for each request, but is only set once as the usable redirect_uri
* on the integration itself i.e. for the client to read using {@link DuoOIDCIntegration#getRedirectURI()}.
@@ -274,4 +368,4 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
}
}
-}
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java
index 900c7414..7e7d7dab 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java
@@ -18,7 +18,6 @@ import java.security.Principal;
import java.text.ParseException;
import java.util.Collection;
import java.util.Map;
-import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -113,8 +112,7 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
*/
public void setContextToPrincipalMappingStrategy(@Nullable final
Function<ProfileRequestContext,Collection<Principal>> hook) {
- ifInitializedThrowUnmodifiabledComponentException();
- ifDestroyedThrowDestroyedComponentException();
+ checkSetterPreconditions();
contextToPrincipalMappingStrategy = hook;
}
@@ -183,7 +181,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
/** {@inheritDoc} */
@Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
@Nonnull final AuthenticationContext authenticationContext) {
-
Map<String, Object> authStatusObject = null;
try {
@@ -310,26 +307,5 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
// Bypass c14n. We already operate on a canonical name, so just re-confirm it.
profileRequestContext.ensureSubcontext(SubjectCanonicalizationContext.class).setPrincipalName(username);
}
-
- /**
- * A default cleanup hook that removes the {@link DuoOIDCAuthenticationContext} from the tree.
- */
- public static class DuoOIDCCleanupHook implements Consumer<ProfileRequestContext> {
-
- /** {@inheritDoc} */
- @Override
- public void accept(@Nullable final ProfileRequestContext input) {
- if (input != null) {
- final AuthenticationContext authnCtx = input.getSubcontext(AuthenticationContext.class);
- if (authnCtx != null) {
- final DuoOIDCAuthenticationContext duoCtx =
- authnCtx.getSubcontext(DuoOIDCAuthenticationContext.class);
- if (duoCtx != null) {
- authnCtx.removeSubcontext(duoCtx);
- }
- }
- }
- }
- }
-}
+}
\ No newline at end of file
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 62402c59..46023528 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
@@ -17,14 +17,13 @@
<bean id="shibboleth.authn.DuoOIDC.externalAuthorizationPath" class="java.lang.String"
c:_0="servletRelative:#{getObject('shibboleth.authn.DuoOIDC.externalServletPath')}#{T(net.shibboleth.idp.plugin.authn.duo.DuoOIDCAuthAPI).AUTHORIZE_PATH_SEGMENT}" />
-
<!-- Default Duo Integration -->
- <bean id="shibboleth.authn.DuoOIDC.DuoIntegration"
+ <bean id="shibboleth.authn.DuoOIDC.DuoIntegration" lazy-init="false"
class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
p:APIHost="%{idp.duo.oidc.apiHost:none}"
p:clientId="%{idp.duo.oidc.clientId:none}"
p:secretKey="%{idp.duo.oidc.secretKey:none}"
- p:registeredRedirectURI="%{idp.duo.oidc.redirectURL:}"
+ p:registeredRedirectURI="%{idp.duo.oidc.redirectURL:}"
p:healthCheckEndpoint="%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}"
p:tokenEndpoint="%{idp.duo.oidc.endpoint.token:/oauth/v1/token}"
p:authorizeEndpoint="%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}"
@@ -32,8 +31,12 @@
<bean id="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
c:target-ref="shibboleth.authn.DuoOIDC.DuoIntegration" />
+ <!-- Default strategy for passwordless integration. -->
+ <bean id="shibboleth.authn.DuoOIDC.PasswordlessDuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
+ c:target="#{getObject('shibboleth.authn.DuoOIDC.PasswordlessDuoIntegration')}" />
+
<!-- Default "optional" non-browser integration. -->
- <bean id="shibboleth.authn.DuoOIDC.NonBrowser.DuoIntegration"
+ <bean id="shibboleth.authn.DuoOIDC.NonBrowser.DuoIntegration" lazy-init="false"
class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
p:APIHost="%{idp.duo.oidc.nonbrowser.apiHost:%{idp.duo.oidc.apiHost:none}}"
p:integrationKey="%{idp.duo.oidc.nonbrowser.integrationKey:none}"
@@ -46,9 +49,9 @@
class="net.shibboleth.idp.session.context.navigate.CanonicalUsernameLookupStrategy" />
<!-- Duo Client factory and bean registry -->
- <bean id="shibboleth.authn.DuoOIDC.clientRegistry" scope="singleton"
- class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoOIDCClientRegistry"
- p:clientFactory-ref="%{idp.duo.oidc.clientFactoryBean:shibboleth.authn.DuoOIDC.clientFactory}"/>
+ <bean id="shibboleth.authn.DuoOIDC.clientRegistry" scope="singleton"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoOIDCClientRegistry"
+ p:clientFactory-ref="%{idp.duo.oidc.clientFactoryBean:shibboleth.authn.DuoOIDC.clientFactory}"/>
<!--
Load all (or none) factory bean definitions from the classpath. The defaulted factory bean must be called
@@ -98,20 +101,27 @@
p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
<!-- Passwordless beans -->
- <bean id="ExtractPasswordlessUsername"
- class="net.shibboleth.idp.plugin.authn.duo.impl.ExtractPasswordlessUsername" scope="prototype"
+ <bean id="CheckPasswordlessEnrollment"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.CheckPasswordlessEnrollment" 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="#{%{idp.authn.DuoOIDC.usernameFieldName:j_username}'.trim()}"
+ p:checkSession="%{idp.authn.DuoOIDC.usernameFromSession:false}"
p:lowercase="%{idp.authn.DuoOIDC.lowercase:false}"
p:uppercase="%{idp.authn.DuoOIDC.uppercase:false}"
p:trim="%{idp.authn.Password.trim:true}"
p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}" />
-
+
+ <alias alias="PasswordlessEnrollmentCheck" name="shibboleth.authn.DuoOIDC.PasswordlessEnrollmentCheck" />
+
<!-- Duo OIDC beans -->
<bean id="PopulateDuoAuthenticationContext" scope="prototype"
class="net.shibboleth.idp.plugin.authn.duo.impl.PopulateDuoAuthenticationContext"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
- p:duoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy"
+ p:standardDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy"
+ p:passwordlessDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.PasswordlessDuoIntegrationStrategy"
p:redirectURICreationStrategy-ref="shibboleth.authn.DuoOIDC.RedirectURICreationStrategy"
p:usernameLookupStrategy-ref="shibboleth.authn.DuoOIDC.UsernameLookupStrategy"
p:clientRegistry-ref="shibboleth.authn.DuoOIDC.clientRegistry" />
@@ -232,7 +242,10 @@
class="net.shibboleth.idp.plugin.authn.duo.impl.ExchangeCodeForDuoToken" />
<bean id="shibboleth.authn.DuoOIDC.DefaultCleanupHook"
- class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult.DuoOIDCCleanupHook" />
+ class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoCleanupHook"
+ 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:}" />
<bean id="ValidateDuoTokenAuthenticationResult" scope="prototype"
class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult"
@@ -246,10 +259,10 @@
<!-- Audit logging beans -->
- <!--
- The first context logger clears any previous audit information from the first authentication factor. The
- others are accumulative.
- -->
+ <!--
+ The first context logger clears any previous audit information from the first authentication factor. The
+ others are accumulative.
+ -->
<bean id="PreDuoPopulateAuditContext" parent="shibboleth.authn.AbstractPopulateAuditContext"
p:fieldExtractors="#{getObject('shibboleth.authn.DuoOIDC.PreDuoPopulateAuditExtractors') ?: getObject('shibboleth.authn.DuoOIDC.DefaultPreDuoPopulateAuditExtractors')}"
p:clearAuditContext="true"/>
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 d00ced49..bd57d8e1 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
@@ -24,31 +24,26 @@
</action-state>
<decision-state id="CheckForPasswordless">
- <if test="opensamlProfileRequestContext.containsSubcontext(T(net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext))"
- then="ExtractPasswordlessUsername"
+ <if test="opensamlProfileRequestContext.containsSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext))"
+ then="CheckPasswordlessEnrollment1"
else="CheckDuoOIDCAuthAPI" />
</decision-state>
- <action-state id="ExtractPasswordlessUsername">
- <evaluate expression="ExtractPasswordlessUsername" />
+ <action-state id="CheckPasswordlessEnrollment1">
+ <evaluate expression="CheckPasswordlessEnrollment" />
<evaluate expression="'proceed'" />
- <transition on="proceed" to="CheckEnrollmentStatus" />
- <transition on="UnknownUsername" to="UsernameCollectionView" />
+ <transition on="proceed" to="PasswordlessView" />
+ <transition on="UnknownUsername" to="PasswordlessView" />
+ <transition on="RequestUnsupported" to="PasswordlessView" />
</action-state>
- <action-state id="CheckEnrollmentStatus">
- <evaluate expression="CheckEnrollmentStatus" />
- <evaluate expression="'proceed'" />
-
- <transition on="proceed" to="UsernameCollectionView" />
- </action-state>
-
- <view-state id="UsernameCollectionView" view="username">
+ <view-state id="PasswordlessView" view="passwordless">
<on-render>
<evaluate expression="environment" result="viewScope.environment" />
<evaluate expression="opensamlProfileRequestContext" result="viewScope.profileRequestContext" />
<evaluate expression="opensamlProfileRequestContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext))" result="viewScope.authenticationContext" />
+ <evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext))" result="viewScope.passwordlessContext" />
<evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.ui.context.RelyingPartyUIContext))" result="viewScope.rpUIContext" />
<evaluate expression="T(net.shibboleth.shared.codec.HTMLEncoder)" result="viewScope.encoder" />
<evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CSPDigester')" result="viewScope.cspDigester" />
@@ -58,16 +53,26 @@
<evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().containsBean('shibboleth.CustomViewContext') ? flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CustomViewContext') : null" result="viewScope.custom" />
</on-render>
- <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
- <transition on="CheckEnrollmentStatus" to="CheckEnrollmentStatus" />
+ <transition on="proceed" to="CheckPasswordlessEnrollment2" />
+ <transition on="cancel" to="RequestUnsupported" />
</view-state>
+ <action-state id="CheckPasswordlessEnrollment2">
+ <evaluate expression="CheckPasswordlessEnrollment" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
+ <transition on="UnknownUsername" to="PasswordlessView" />
+ <transition on="RequestUnsupported" to="PasswordlessView" />
+ </action-state>
+
<action-state id="CheckDuoOIDCAuthAPI">
<evaluate expression="PopulateDuoAuthenticationContext" />
<evaluate expression="HealthCheckDuoOIDCAuthAPI" />
<evaluate expression="PreDuoPopulateAuditContext" />
<evaluate expression="WritePreDuoAuthnAuditLog"/>
<evaluate expression="'proceed'" />
+
<transition on="proceed" to="Duo2FAAuthorizationRequest" />
</action-state>
@@ -89,6 +94,7 @@
<evaluate expression="PreStateValidationPopulateAuditContext" />
<evaluate expression="ValidateDuoResponseState"/>
<evaluate expression="'proceed'" />
+
<transition on="proceed" to="ExchangeCodeForDuoToken" />
</action-state>
@@ -98,8 +104,9 @@
<evaluate expression="ValidateTokenSignature"/>
<evaluate expression="ValidateTokenClaims"/>
<!-- final validation of the response status to build an authn result -->
- <evaluate expression="ValidateDuoTokenAuthenticationResult"/>
+ <evaluate expression="ValidateDuoTokenAuthenticationResult"/>
<evaluate expression="'proceed'" />
+
<transition on="proceed" to="proceed" />
</action-state>
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/passwordless.vm
similarity index 89%
rename from idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
rename to idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/passwordless.vm
index 6a7c9845..13c35e05 100644
--- a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/passwordless.vm
@@ -7,6 +7,7 @@
## flowExecutionKey - the SWF execution key (this is built into the flowExecutionUrl)
## profileRequestContext - root of context tree
## authenticationContext - context with authentication request information
+## passwordlessContext - context with Duo username and enrollment status
## rpUIContext - the context with SP UI information from the metadata
## encoder - HTMLEncoder class
## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
@@ -17,7 +18,7 @@
## custom - arbitrary object injected by deployer
##
#set ($rpContext = $profileRequestContext.getSubcontext('net.shibboleth.profile.context.RelyingPartyContext'))
-#set ($username = $authenticationContext.getSubcontext('net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext').getUsername())
+#set ($username = $passwordlessContext.getUsername())
##
<!DOCTYPE html>
<html>
@@ -68,12 +69,16 @@
value="#if($username)$encoder.encodeForHTML($username)#end" />
<input type="checkbox" name="donotcache" value="1" id="donotcache" />
- <label for="donotcache">#springMessageText("idp.duo.donotcache", "Don't Remember Me")</label>
+ <label for="donotcache">#springMessageText("idp.login.donotcache", "Don't Remember Login")</label>
<div class="grid">
<div class="grid-item">
<button type="submit" name="_eventId_proceed"
- >#springMessageText("idp.duo.continue", "Continue")</button>
+ >#springMessageText("idp.duo.passwordless", "Passwordless Login")</button>
+ </div>
+ <div class="grid-item">
+ <button type="submit" name="_eventId_cancel"
+ >#springMessageText("idp.duo.cancel", "Password Login")</button>
</div>
</div>
</form>
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list