[java-idp-plugin-duo] branch main updated: JDUO-80 - Use of Duo as a Passwordless solution
Scott Cantor
cantor.2 at osu.edu
Fri Apr 5 16:55:21 UTC 2024
This is an automated email from the git hooks/post-receive script.
scantor pushed a commit to branch main
in repository java-idp-plugin-duo.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-duo.git;a=commit;h=df1145c71415cdf8e963312aa61022756dd891df
The following commit(s) were added to refs/heads/main by this push:
new df1145c7 JDUO-80 - Use of Duo as a Passwordless solution
df1145c7 is described below
commit df1145c71415cdf8e963312aa61022756dd891df
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Fri Apr 5 12:55:18 2024 -0400
JDUO-80 - Use of Duo as a Passwordless solution
https://shibboleth.atlassian.net/browse/JDUO-80
Revamped design using an opt-in view governed by a pluggable condition.
Username now comes from cookie only and is read-only.
Corrected issues around the shared machine and revoke consent options.
Revamped properties and cleaned up some naming issues.
---
.../authn/duo/DefaultPasswordlessCondition.java | 86 ++++
.../DefaultPasswordlessEnrollmentCondition.java | 227 ----------
.../duo/context/DuoOIDCAuthenticationContext.java | 30 +-
.../authn/duo/context/DuoPasswordlessContext.java | 39 +-
.../duo/impl/CheckPasswordlessEnrollment.java | 493 ---------------------
.../authn/duo/impl/CreatePasswordlessCookie.java | 125 ++++++
.../duo/impl/PopulateDuoAuthenticationContext.java | 40 +-
.../duo/impl/PopulatePasswordlessContext.java | 179 ++++++++
.../impl/PostValidatePasswordlessEvaluation.java | 269 +++++++++++
.../impl/ValidateDuoTokenAuthenticationResult.java | 23 +-
.../META-INF/net.shibboleth.idp/postconfig.xml | 43 --
.../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml | 59 +--
.../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml | 59 ++-
.../idp/plugin/authn/duo/messages.properties | 12 +-
.../plugin/authn/duo/views/passwordless-optin.vm | 62 +++
.../idp/plugin/authn/duo/views/passwordless.vm | 32 +-
.../impl/AbstractAuthnXmlFlowExecutionTests.java | 6 -
.../duo/impl/CheckPasswordlessEnrollmentTest.java | 348 ---------------
.../duo/nimbus/conf/authn/duo-oidc.properties | 12 +-
.../idp/plugin/authn/duo/nimbus/module.properties | 2 +
.../authn/duo/sdk/conf/authn/duo-oidc.properties | 12 +-
.../idp/plugin/authn/duo/sdk/module.properties | 2 +
22 files changed, 905 insertions(+), 1255 deletions(-)
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessCondition.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessCondition.java
new file mode 100644
index 00000000..2290b44d
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessCondition.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.duo;
+
+import java.util.Collection;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * A {@link Predicate} which determines whether a user/device should be allowed,
+ * on subsequent requests, to leverage passwordless authentication.
+ *
+ * <p>This is a hook to nmanage the adoption of passwordless by
+ * controlling under what conditions a device will receive a cookie that
+ * authorizes the option when invoked in that mode. The default condition
+ * is driven solely by examining the factor used by the user, but this
+ * may be combined by deployers with other custom conditions.</p>
+ *
+ * @since 2.1.0
+ */
+public class DefaultPasswordlessCondition extends AbstractInitializableComponent
+ implements Predicate<ProfileRequestContext> {
+
+ /** Allowed factors that qualify. */
+ @Nonnull @NonnullElements private Set<String> allowedFactors;
+
+ /** Constructor. */
+ public DefaultPasswordlessCondition() {
+ allowedFactors = CollectionSupport.emptySet();
+ }
+
+ /**
+ * Sets the allowable Duo factors that qualify for the condition.
+ *
+ * @param factors allowed factors
+ */
+ public void setAllowedFactors(@Nonnull @NonnullElements final Collection<String> factors) {
+ checkSetterPreconditions();
+ allowedFactors = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(factors));
+ }
+
+ /** {@inheritDoc} */
+ public boolean test(@Nullable final ProfileRequestContext input) {
+ checkComponentActive();
+
+ final AuthenticationContext authenticationContext = input != null ?
+ input.getSubcontext(AuthenticationContext.class) : null;
+
+ if (authenticationContext == null) {
+ return false;
+ }
+
+ final DuoOIDCAuthenticationContext duoContext =
+ authenticationContext.getSubcontext(DuoOIDCAuthenticationContext.class);
+ if (duoContext == null || duoContext.getFactorUsed() == null) {
+ return false;
+ }
+
+ return allowedFactors.contains(duoContext.getFactorUsed());
+ }
+
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessEnrollmentCondition.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessEnrollmentCondition.java
deleted file mode 100644
index 84138249..00000000
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessEnrollmentCondition.java
+++ /dev/null
@@ -1,227 +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;
-
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-import java.util.function.BiPredicate;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-
-import org.opensaml.profile.context.ProfileRequestContext;
-import org.slf4j.Logger;
-
-import jakarta.servlet.http.HttpServletRequest;
-import net.shibboleth.idp.authn.AccountLockoutManager;
-import net.shibboleth.idp.plugin.authn.duo.model.User;
-import net.shibboleth.idp.plugin.authn.duo.model.WebAuthnCredential;
-import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
-import net.shibboleth.shared.annotation.constraint.NonnullElements;
-import net.shibboleth.shared.collection.CollectionSupport;
-import net.shibboleth.shared.component.AbstractInitializableComponent;
-import net.shibboleth.shared.component.ComponentInitializationException;
-import net.shibboleth.shared.logic.Constraint;
-import net.shibboleth.shared.primitive.LoggerFactory;
-import net.shibboleth.shared.primitive.NonnullSupplier;
-import net.shibboleth.shared.primitive.StringSupport;
-import net.shibboleth.shared.servlet.HttpServletSupport;
-
-/**
- * A BiPredicate which checks the enrollment status of a user against the Duo Admin APIs.
- *
- * <p>The second parameter is the username to evaluate.</p>
- *
- * @since 2.1.0
- */
-public class DefaultPasswordlessEnrollmentCondition extends AbstractInitializableComponent
- implements BiPredicate<ProfileRequestContext,String> {
-
- /** Class logger. */
- @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultPasswordlessEnrollmentCondition.class);
-
- /** The admin client used to access the DuoAdmin API.*/
- @NonnullAfterInit private DuoAdminClient adminClient;
-
- /** Allowed WebAuthnCredential labels that qualify. */
- @Nonnull @NonnullElements private Set<String> allowedLabels;
-
- /** Optional lockout tracker for rate limiting. */
- @Nullable private AccountLockoutManager lockoutManager;
-
- /** Constructor. */
- public DefaultPasswordlessEnrollmentCondition() {
- allowedLabels = CollectionSupport.emptySet();
- }
-
- /**
- * Sets the {@link DuoAdminClient} to use.
- *
- * @param client admin client
- */
- public void setDuoAdminClient(@Nonnull final DuoAdminClient client) {
- checkSetterPreconditions();
- adminClient = client;
- }
-
- /**
- * Sets the allowable {@link WebAuthnCredential} labels that qualify for the condition.
- *
- * @param labels credential labels
- */
- public void setAllowedLabels(@Nonnull @NonnullElements final Collection<String> labels) {
- checkSetterPreconditions();
- allowedLabels = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(labels));
- }
-
- /**
- * Sets the optional {@link AccountLockoutManager} to use for rate limiting.
- *
- * @param manager lockout manager
- */
- public void setLockoutManager(@Nullable final AccountLockoutManager manager) {
- checkSetterPreconditions();
- lockoutManager = manager;
- }
-
- /** {@inheritDoc} */
- @Override
- public void doInitialize() throws ComponentInitializationException {
- super.doInitialize();
-
- if (adminClient == null) {
- throw new ComponentInitializationException("DuoAdminClient cannot be null");
- }
- }
-
-// Checkstyle: CyclomaticComplexity|ReturnCount OFF
- /** {@inheritDoc} */
- public boolean test(@Nullable final ProfileRequestContext profileRequestContext, @Nullable final String username) {
- checkComponentActive();
-
- if (profileRequestContext == null || username == null) {
- log.trace("Unable to check passwordless enrollment status for '{}'", username);
- return false;
- }
-
- log.trace("Checking passwordless enrollment status for '{}'", username);
- try {
-
- if (lockoutManager != null) {
- if (lockoutManager.check(profileRequestContext)) {
- log.warn("Lockout manager precludes enrollment check for '{}'", username);
- return false;
- }
-
- assert lockoutManager != null;
- lockoutManager.increment(profileRequestContext);
- }
-
- final User response = adminClient.getUser(profileRequestContext, username);
- if (response == null) {
- log.info("User '{}' not found in Duo", username);
- return false;
- }
-
- final Boolean enrolled = response.isEnrolled();
- if (enrolled == null || !enrolled) {
- log.info("User '{}' not enrolled", response.getUsername());
- return false;
- }
-
- final List<WebAuthnCredential> creds = response.getWebAuthnCredentials();
- if (creds.isEmpty()) {
- log.info("User '{}' has no WebAuthn credentials", response.getUsername());
- return false;
- }
-
- if (allowedLabels.isEmpty()) {
- log.debug("User '{}' is enrolled with {} WebAuthn credential(s)", response.getUsername(),
- response.getWebAuthnCredentials().size());
- return true;
- }
-
- final Set<String> enrolledLabels = creds.stream()
- .map(WebAuthnCredential::getLabel)
- .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet())).get();
- for (final String label : allowedLabels) {
- if (enrolledLabels.contains(label)) {
- log.debug("User '{}' has a qualifying WebAuthn credential: '{}'", response.getUsername(), label);
- return true;
- }
- }
-
- log.info("User '{}' has no acceptable WebAuthn credential, enrolled credentials: {}",
- response.getUsername(), enrolledLabels);
- return false;
-
- } catch (final DuoException e) {
- log.warn("Duo AdminAPI request failed, denying passwordless for '{}'", username, e);
- return false;
- }
- }
-// Checkstyle: CyclomaticComplexity|ReturnCount ON
-
- /**
- * A function to generate a key for lockout storage, just the client address.
- */
- public static class IPLockoutKeyStrategy implements Function<ProfileRequestContext,String> {
-
- /** Supplier for the Servlet request to pull client ip from. **/
- @Nullable private NonnullSupplier<HttpServletRequest> httpRequestSupplier;
-
- /**
- * Set the Supplier for the servlet request to read from.
- *
- * @param requestSupplier servlet request Supplier
- */
- public void setHttpServletRequestSupplier(@Nonnull final NonnullSupplier<HttpServletRequest> requestSupplier) {
- httpRequestSupplier = Constraint.isNotNull(requestSupplier, "HttpServletRequest cannot be null");
- }
-
- /**
- * Get the current HTTP request if available.
- *
- * @return current HTTP request
- */
- @Nullable private HttpServletRequest getHttpServletRequest() {
- if (httpRequestSupplier == null) {
- return null;
- }
- assert httpRequestSupplier != null;
- return httpRequestSupplier.get();
- }
-
- /** {@inheritDoc} */
- @Nullable public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
-
- final HttpServletRequest request = getHttpServletRequest();
- if (request == null) {
- return null;
- }
-
- final String ipAddr = HttpServletSupport.getRemoteAddr(request);
- if (ipAddr == null || ipAddr.isEmpty()) {
- return null;
- }
-
- return ipAddr;
- }
- }
-
-}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java
index 6f3f585c..a94920eb 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java
@@ -60,6 +60,9 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
/** The JWT token received from Duo as a result of 2FA. Token *must* be signed.*/
@Nullable private JWT authToken;
+ /** The factor claim from the token. */
+ @Nullable private String factorUsed;
+
/** The Duo OIDC client to use for the lifetime of this authentication request.*/
@Nullable private DuoOIDCClient client;
@@ -181,6 +184,31 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
return authToken;
}
+ /**
+ * Set the factor claim from the token.
+ *
+ * @param factor factor claim
+ *
+ * @return this context
+ *
+ * @since 2.1.0
+ */
+ @Nonnull public DuoOIDCAuthenticationContext setFactorUsed(@Nullable final String factor) {
+ factorUsed = factor;
+ return this;
+ }
+
+ /**
+ * Get the factor claim from the token.
+ *
+ * @return factor claim
+ *
+ * @since 2.1.0
+ */
+ @Nullable public String getFactorUsed() {
+ return factorUsed;
+ }
+
/**
* Get the request state.
*
@@ -190,7 +218,6 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
return requestState;
}
-
/**
* Set the request state.
*
@@ -266,5 +293,4 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
return integration;
}
-
}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
index 26627774..114e5d52 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
@@ -14,8 +14,6 @@
package net.shibboleth.idp.plugin.authn.duo.context;
-import java.util.Objects;
-
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -28,10 +26,10 @@ import net.shibboleth.idp.authn.context.AuthenticationContext;
*
* <p>This is used for a more specialized use of the Duo service as a single factor.
* The presence of the context acts as a signal of this behavior, and the username
- * is tracked here since it is typicall set by calling code or collected from a view.</p>
+ * is tracked here since it is typically set by calling code.</p>
*
* @parent {@link AuthenticationContext}
- * @added By configuration to signal username collection and enrollment checking
+ * @added By flow action
*
* @since 2.1.0
*/
@@ -40,9 +38,6 @@ public final class DuoPasswordlessContext extends BaseContext {
/** Username. */
@Nullable private String username;
- /** Whether user has appropriate devices enrolled. */
- boolean enrolled;
-
/**
* Get the username.
*
@@ -55,39 +50,13 @@ 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) {
- if (!Objects.equals(name, username)) {
- username = name;
- enrolled = false;
- }
- return this;
- }
-
- /**
- * Gets whether the user is determined to have appropriate devices enrolled.
- *
- * @return whether the user is determined to have appropriate devices enrolled
- */
- public boolean isEnrolled() {
- return enrolled;
- }
-
- /**
- * Sets whether the user is determined to have appropriate devices enrolled.
- *
- * @param flag flag to set
- *
- * @return this context
- */
- @Nonnull public DuoPasswordlessContext setEnrolled(final boolean flag) {
- enrolled = flag;
+ username = name;
return this;
}
+
}
\ No newline at end of file
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
deleted file mode 100644
index 13e3004f..00000000
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
+++ /dev/null
@@ -1,493 +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.Collection;
-import java.util.List;
-import java.util.function.BiFunction;
-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.collection.CollectionSupport;
-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 {@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;
-
- /** Whether to signal non-proceed events. */
- private boolean signalEvents;
-
- /** 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;
-
- /** Order to pull username from. */
- @Nonnull private List<String> precedence;
-
- /** Generic hook for remapping username. */
- @Nullable private BiFunction<ProfileRequestContext,String,String> duoUsernameRemappingStrategy;
-
- /** Context to operate on. */
- @NonnullBeforeExec private DuoPasswordlessContext passwordlessContext;
-
- /** Constructor.*/
- public CheckPasswordlessEnrollment() {
- duoPasswordlessContextLookupStrategy =
- new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
- new ChildContextLookup<>(AuthenticationContext.class));
-
- // TODO: BiPredicateSupport.alwaysTrue once API is bumped.
- passwordlessCondition = (a,b) -> {
- return true;
- };
-
- usernameFieldName = "j_username";
- ssoBypassFieldName = "donotcache";
-
- precedence = CollectionSupport.listOf("form", "session", "cookie");
- }
-
- /**
- * 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 whether the action should signal non-proceed events in defined cases, or suppress them.
- *
- * <p>Defaults to false.</p>
- *
- * @param flag flag to set
- */
- public void setSignalEvents(final boolean flag) {
- checkSetterPreconditions();
-
- signalEvents = flag;
- }
-
- /**
- * 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 the precedence rules to use in populating the username.
- *
- * <p>Valid tokens are "form", "session", and "cookie" and that is the default order.</p>
- *
- * @param order precedence to use
- */
- public void setPrecedence(@Nonnull final Collection<String> order) {
- checkSetterPreconditions();
-
- precedence = CollectionSupport.copyToList(StringSupport.normalizeStringCollection(order));
- }
-
- /**
- * Sets a general hook for remapping username.
- *
- * @param strategy username remapping strategy
- */
- public void setDuoUsernameRemappingStrategy(
- @Nullable final BiFunction<ProfileRequestContext,String,String> strategy) {
- // TODO: Remove once API moved to 5.1
- checkSetterPreconditions();
-
- duoUsernameRemappingStrategy = strategy;
- }
-
- /** {@inheritDoc} */
- @Override
- protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
- @Nonnull final AuthenticationContext authenticationContext) {
-
- if (!super.doPreExecute(profileRequestContext, authenticationContext)) {
- return false;
- }
-
- passwordlessContext = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
- if (passwordlessContext == null) {
- log.debug("{} No DuoPasswordlessContext found, nothing to do", getLogPrefix());
- return false;
- }
-
- return true;
- }
-
-// Checkstyle: CyclomaticComplexity|MethodLength OFF
- /** {@inheritDoc} */
- @Override
- protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
- @Nonnull final AuthenticationContext authenticationContext) {
-
- setCacheability(authenticationContext);
-
- boolean usernameChanged = false;
- boolean exitLoop = false;
-
- String username;
-
- for (final String source : precedence) {
- switch (source) {
- case "form":
- username = getUsernameFromForm(profileRequestContext, authenticationContext);
- if (username != null) {
- if (!username.equals(passwordlessContext.getUsername())) {
- log.debug("{} Populating username '{}' from form submission into DuoPasswordlessContext",
- getLogPrefix(), username);
- passwordlessContext.setUsername(username);
- usernameChanged = true;
- }
- exitLoop = true;
- }
- break;
-
- case "session":
- username = getUsernameFromSession(profileRequestContext, authenticationContext);
- if (username != null) {
- if (!username.equals(passwordlessContext.getUsername())) {
- log.debug("{} Populating username '{}' from session into DuoPasswordlessContext",
- getLogPrefix(), username);
- passwordlessContext.setUsername(username);
- usernameChanged = true;
- }
- exitLoop = true;
- }
- break;
-
- case "cookie":
- username = getUsernameFromCookie(profileRequestContext);
- if (username != null) {
- if (!username.equals(passwordlessContext.getUsername())) {
- log.debug("{} Populating cached username '{}' from cookie into DuoPasswordlessContext",
- getLogPrefix(), username);
- passwordlessContext.setUsername(username);
- usernameChanged = true;
- }
- exitLoop = true;
- }
- break;
-
- default:
- log.warn("{} Unsupported precedence value for username population: {}", getLogPrefix(), source);
- break;
- }
-
- if (exitLoop) {
- break;
- }
- }
-
- // Clear cookie if required.
- if (!authenticationContext.isResultCacheable() && cookieManager != null && cookieName != null) {
- cookieManager.unsetCookie(cookieName);
- }
-
- final String finalUsername = passwordlessContext.getUsername();
- if (finalUsername == null || finalUsername.isBlank()) {
- passwordlessContext.setUsername(null);
- passwordlessContext.setEnrolled(false);
- if (signalEvents) {
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
- }
- return;
- }
-
- if (usernameChanged) {
- passwordlessContext.setEnrolled(
- passwordlessCondition.test(profileRequestContext, finalUsername));
- log.debug("{} Username '{}' found to be {} of passwordless attempt", getLogPrefix(), finalUsername,
- passwordlessContext.isEnrolled() ? "capable" : "incapable");
-
- // Upodate cookie if needed.
- updateCookie(authenticationContext);
- } else {
- log.debug("{} Username not updated, leaving DuoPasswordlessContext unchanged", getLogPrefix());
- }
-
- if (signalEvents && !passwordlessContext.isEnrolled()) {
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
- }
- }
-// Checkstyle: CyclomaticComplexity|MethodLength ON
-
- /**
- * Establishes result cacheability from form field.
- *
- * @param authenticationContext authentication context
- */
- private void setCacheability(@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);
- }
- }
- }
-
- /**
- * Set or unset cokie based on enrollment status and result cacheability.
- *
- * @param authenticationContext authentication context
- */
- private void updateCookie(@Nonnull final AuthenticationContext authenticationContext) {
- if (passwordlessContext.isEnrolled()) {
- final String localCookieName = cookieName;
- if (authenticationContext.isResultCacheable()) {
- if (cookieManager != null && dataSealer != null && localCookieName != null) {
- String wrapped = passwordlessContext.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);
- }
- }
- }
-
- /**
- * Gets the username from a form submission.
- *
- * @param profileRequestContext profile request context
- * @param authenticationContext authentication context
- *
- * @return submitted username, after applying any configured transforms
- */
- @Nullable private String getUsernameFromForm(@Nonnull final ProfileRequestContext profileRequestContext,
- @Nonnull final AuthenticationContext authenticationContext) {
-
- final HttpServletRequest request = getHttpServletRequest();
- if (request != null) {
- final String param = request.getParameter(usernameFieldName);
- if (param != null) {
- // TODO: Convert to 2 parameter version once API moves to 5.1.
- final String s = applyTransforms(param);
- return duoUsernameRemappingStrategy != null
- ? duoUsernameRemappingStrategy.apply(profileRequestContext, s) : s;
- }
- }
-
- return null;
- }
-
- /**
- * Gets the username from an existing sealed cookie, if any.
- *
- * @param profileRequestContext profile request context
- *
- * @return username from existing sealed cookie, or null
- */
- @Nullable @NotEmpty private String getUsernameFromCookie(
- @Nonnull final ProfileRequestContext profileRequestContext) {
-
- if (cookieManager != null && dataSealer != null && cookieName != null) {
- final String cookie = URISupport.doURLDecode(cookieManager.getCookieValue(cookieName, null));
- if (cookie != null) {
- try {
- assert dataSealer != null;
- // TODO: Convert to 2 parameter version once API moves to 5.1.
- return applyTransforms(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 @NotEmpty private String getUsernameFromSession(
- @Nonnull final ProfileRequestContext profileRequestContext,
- @Nonnull final AuthenticationContext authenticationContext) {
-
- if (!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-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CreatePasswordlessCookie.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CreatePasswordlessCookie.java
new file mode 100644
index 00000000..61ff2ed2
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CreatePasswordlessCookie.java
@@ -0,0 +1,125 @@
+/*
+ * 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 javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+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 net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.SubjectCanonicalizationContext;
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+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;
+
+/**
+ * Finalization action that creates the passwordless guard cookkie based on the
+ * canonical principal name after the flow completes.
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link AuthnEventIds#INVALID_SUBJECT_C14N_CTX}
+ *
+ * @pre <pre>
+ * ProfileRequestContext.ensureSubcontext(SubjectCanonicalizationContext.class).getPrincipalName() != null
+ * </pre>
+ *
+ * @since 2.1.0
+ */
+public class CreatePasswordlessCookie extends AbstractProfileAction {
+
+ /** Class logger.*/
+ @Nonnull private final Logger log = LoggerFactory.getLogger(CreatePasswordlessCookie.class);
+
+ /** Passwordless 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 "authorizing" passwordless use.
+ *
+ * @param name cookie name
+ */
+ public void setCookieName(@Nullable final String name) {
+ checkSetterPreconditions();
+
+ cookieName = StringSupport.trimOrNull(name);
+ }
+
+ /**
+ * Sets {@link CookieManager} to use.
+ *
+ * @param manager cookie manager
+ */
+ public void setCookieManager(@Nullable final CookieManager manager) {
+ checkSetterPreconditions();
+
+ cookieManager = manager;
+ }
+
+ /**
+ * Sets {@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) {
+
+ final String localCookieName = cookieName;
+ final CookieManager localManager = cookieManager;
+ final DataSealer localSealer = dataSealer;
+ if (localCookieName == null || localManager == null || localSealer == null) {
+ log.warn("{} Cookie management settings are absent, this shouldn't be possible");
+ return;
+ }
+
+ final SubjectCanonicalizationContext c14nContext =
+ profileRequestContext.getSubcontext(SubjectCanonicalizationContext.class);
+ final String username = c14nContext != null ? c14nContext.getPrincipalName() : null;
+ if (username == null) {
+ log.error("{} No username available", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.INVALID_SUBJECT_C14N_CTX);
+ return;
+ }
+
+ try {
+ final String wrapped = localSealer.wrap(username);
+ localManager.addCookie(localCookieName, UrlEscapers.urlFormParameterEscaper().escape(wrapped));
+ } catch (final DataSealerException e) {
+ log.warn("{} Unable to wrap username for guard cookie", getLogPrefix(), e);
+ }
+ }
+
+}
\ 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 91020c3f..98898b3c 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
@@ -39,10 +39,12 @@ 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.annotation.constraint.NotEmpty;
import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.logic.FunctionSupport;
import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
/**
@@ -83,6 +85,9 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
/** Strategy used to compute the redirectURI from the given Duo integration if supported.*/
@Nullable private BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> redirectURICreationStrategy;
+
+ /** Parameter name for SSO bypass. */
+ @Nonnull @NotEmpty private String ssoBypassFieldName;
/** The registry for locating the DuoClient for the established integration.*/
@NonnullAfterInit private DuoOIDCClientRegistry clientRegistry;
@@ -96,6 +101,8 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
usernameLookupStrategy = new CanonicalUsernameLookupStrategy();
standardDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
passwordlessDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
+
+ ssoBypassFieldName = "donotcache";
}
/**
@@ -176,7 +183,20 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
passwordlessDuoIntegrationLookupStrategy =
Constraint.isNotNull(strategy, "Passwordless DuoIntegration lookup strategy cannot be null");
}
-
+
+ /**
+ * Set the SSO bypass parameter name.
+ *
+ * @param fieldName the SSO bypass parameter name
+ *
+ * @since 2.1.0
+ */
+ public void setSSOBypassFieldName(@Nonnull @NotEmpty final String fieldName) {
+ checkSetterPreconditions();
+ ssoBypassFieldName = Constraint.isNotNull(
+ StringSupport.trimOrNull(fieldName), "SSO Bypass field name cannot be null or empty.");
+ }
+
/** {@inheritDoc} */
@Override protected void doInitialize() throws ComponentInitializationException {
super.doInitialize();
@@ -205,7 +225,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
final DuoPasswordlessContext passwordlessContext =
passwordlessContextLookupStrategy.apply(profileRequestContext);
if (passwordlessContext != null) {
- if (!doPasswordless(profileRequestContext, duoContext, passwordlessContext)) {
+ if (!doPasswordless(profileRequestContext, authenticationContext, duoContext, passwordlessContext)) {
duoContext.removeFromParent();
return;
}
@@ -246,12 +266,14 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
* Perform standard context creation and lookups.
*
* @param profileRequestContext profile request context
+ * @param authenticationContext authentication 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 AuthenticationContext authenticationContext,
@Nonnull final DuoOIDCAuthenticationContext duoContext,
@Nonnull final DuoPasswordlessContext passwordlessContext) {
@@ -261,11 +283,6 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
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) {
@@ -280,6 +297,15 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
duoContext.setIntegration(duoIntegration);
+ 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);
+ }
+ }
+
return true;
}
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulatePasswordlessContext.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulatePasswordlessContext.java
new file mode 100644
index 00000000..4fb3a494
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulatePasswordlessContext.java
@@ -0,0 +1,179 @@
+/*
+ * 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 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.profile.AbstractProfileAction;
+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;
+
+/**
+ * A profile action to extract passwordless username from sealed cookie.
+ *
+ * @since 2.1.0
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#REQUEST_UNSUPPORTED}
+ * @pre <pre>AuthenticationContext.getSubcontext(DuoPasswordlessContext.class) != null</pre>
+ * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found
+ */
+public class PopulatePasswordlessContext extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(PopulatePasswordlessContext.class);
+
+ /** Strategy used to locate the {@link DuoPasswordlessContext} to operate on. */
+ @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
+
+ /** Passwordless cookie name. */
+ @NonnullBeforeExec @NotEmpty private String cookieName;
+
+ /** Optional cookie manager to use. */
+ @NonnullBeforeExec private CookieManager cookieManager;
+
+ /** Optional data sealer to use. */
+ @NonnullBeforeExec private DataSealer dataSealer;
+
+ /** Context to populate. */
+ @NonnullBeforeExec private DuoPasswordlessContext passwordlessContext;
+
+ /** Constructor. */
+ public PopulatePasswordlessContext() {
+ duoPasswordlessContextLookupStrategy =
+ new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
+ new ChildContextLookup<>(AuthenticationContext.class));
+
+ }
+
+ /**
+ * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setDuoPasswordlessContextLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
+ checkSetterPreconditions();
+
+ duoPasswordlessContextLookupStrategy =
+ Constraint.isNotNull(strategy, "DuoPasswordlessContext lookup strategy cannot be null");
+ }
+
+ /**
+ * Set cookie name to use for "authorizing" passwordless use.
+ *
+ * @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;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ if (!super.doPreExecute(profileRequestContext)) {
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+ return false;
+ }
+
+ if (cookieName == null || dataSealer == null || cookieManager == null) {
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+ return false;
+ }
+
+ passwordlessContext = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
+ if (passwordlessContext == null) {
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ assert cookieName != null;
+ String cookie = cookieManager.getCookieValue(cookieName, null);
+ if (cookie == null) {
+ log.debug("{} Guard cookie missing from request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+ return;
+ }
+
+ cookie = URISupport.doURLDecode(cookie);
+ if (cookie == null) {
+ log.debug("{} Unable to decode guard cookie", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+ return;
+ }
+
+ try {
+ final String username = dataSealer.unwrap(cookie);
+ passwordlessContext.setUsername(username);
+ log.debug("{} Extracted username for passwordless authentication from cookie: {}", getLogPrefix(),
+ username);
+ } catch (final DataSealerException e) {
+ log.info("{} Unable to decrypt passwordless guard cookie", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PostValidatePasswordlessEvaluation.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PostValidatePasswordlessEvaluation.java
new file mode 100644
index 00000000..7082ffa8
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PostValidatePasswordlessEvaluation.java
@@ -0,0 +1,269 @@
+/*
+ * 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.Consumer;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.AbstractAuthenticationAction;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.PredicateSupport;
+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;
+
+/**
+ * This is a convoluted step that implements some of the cookie management logic
+ * needed after second-factor use, but before the possible opt-in to passwordless.
+ *
+ * <p>The {@link EventIds#PROCEED_EVENT_ID} event is a signal that no further steps are
+ * required and the flow should complete as is.</p>
+ *
+ * <p>The {@link #PROMPT_USER_EVENT} event is a signal that the user should be asked to
+ * opt into passwordless use in the future, resulting in the creation of a guard cookie.</p>
+ *
+ * <p>This action will also remove the cookie if a non-cacheable login happens and is set
+ * to react to that, and will remove the cookie if the existing cookie's value doesn't
+ * match the username in the transaction.</p>
+ *
+ * <p>This is also where the cleanup hook is relocated, in order to defer that step and
+ * preserve access to the Duo state.</p>
+ *
+ * @event {@link #PROMPT_USER_EVENT}
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @pre <pre>
+ * ProfileRequestContext.getSubcontext(AuthenticationContext.class) != null
+ * </pre>
+ *
+ * @pre <pre>
+ * AuthenticationContext.getSubcontext(DuoOIDCAuthenticationContext.class) != null
+ * </pre>
+ *
+ * @pre <pre>
+ * DuoOIDCAuthenticationContext.getDuoIntegration() != null
+ * </pre>
+ *
+ * @since 2.1.0
+ */
+public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAction {
+
+ /** Custom event to signal that a prompt to opt into passwordless should be presented. */
+ @Nonnull @NotEmpty public static final String PROMPT_USER_EVENT = "PasswordlessPrompt";
+
+ /** Class logger.*/
+ @Nonnull private final Logger log = LoggerFactory.getLogger(PostValidatePasswordlessEvaluation.class);
+
+ /** A cleanup hook to execute after processing. */
+ @Nullable private Consumer<ProfileRequestContext> cleanupHook;
+
+ /** Condition governing "new" eligibility. */
+ @Nonnull private Predicate<ProfileRequestContext> passwordlessCondition;
+
+ /** Whether to require the authentication be cacheable to allow this. */
+ private boolean requireResultCacheable;
+
+ /** Passwordless 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;
+
+ /** Duo authentiction context. */
+ @NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
+
+ /** Constructor. */
+ public PostValidatePasswordlessEvaluation() {
+ passwordlessCondition = PredicateSupport.alwaysFalse();
+ requireResultCacheable = true;
+ }
+
+ /**
+ * Set the cleanup hook to execute after processing.
+ *
+ * @param hook cleanup hook
+ */
+ public void setCleanupHook(@Nullable final Consumer<ProfileRequestContext> hook) {
+ checkSetterPreconditions();
+ cleanupHook = hook;
+ }
+
+ /**
+ * Set condition governing eligibility for passwordless opt-in.
+ *
+ * @param condition
+ */
+ public void setPasswordlessCondition(@Nonnull final Predicate<ProfileRequestContext> condition) {
+ checkSetterPreconditions();
+
+ passwordlessCondition = Constraint.isNotNull(condition, "Passwordless eligibility condition cannot be null");
+ }
+
+ /**
+ * Sets whether a non-cacheable result should force the condition to return false.
+ *
+ * <p>This defaults to "true" which subsequently honors the "do not remember" option
+ * on the login views.</p>
+ *
+ * @param flag flag to set
+ */
+ public void setRequireResultCacheable(final boolean flag) {
+ checkSetterPreconditions();
+ requireResultCacheable = flag;
+ }
+
+ /**
+ * Set cookie name to use for "authorizing" passwordless use.
+ *
+ * @param name cookie name
+ */
+ public void setCookieName(@Nullable final String name) {
+ checkSetterPreconditions();
+
+ cookieName = StringSupport.trimOrNull(name);
+ }
+
+ /**
+ * Sets {@link CookieManager} to use.
+ *
+ * @param manager cookie manager
+ */
+ public void setCookieManager(@Nullable final CookieManager manager) {
+ checkSetterPreconditions();
+
+ cookieManager = manager;
+ }
+
+ /**
+ * Sets {@link DataSealer} to use.
+ *
+ * @param sealer data sealer
+ */
+ public void setDataSealer(@Nullable final DataSealer sealer) {
+ checkSetterPreconditions();
+
+ dataSealer = sealer;
+ }
+
+ /** {@inheritDoc} */
+ @Override protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final AuthenticationContext authenticationContext) {
+
+ // This needs to be true so the post-execute method runs, and thus the cleanup hook.
+ return true;
+ }
+
+// Checkstyle: CyclomaticComplexity OFF
+ /** {@inheritDoc} */
+ @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final AuthenticationContext authenticationContext) {
+
+
+ final String localCookieName = cookieName;
+ final CookieManager localManager = cookieManager;
+ final DataSealer localSealer = dataSealer;
+ if (localCookieName == null || localManager == null || localSealer == null) {
+ log.trace("{} Cookie management settings are absent, skipping this step");
+ return;
+ }
+
+ duoContext = authenticationContext.getSubcontext(DuoOIDCAuthenticationContext.class);
+ if (duoContext == null) {
+ log.error("{} No DuoAuthenticationContext available", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return;
+ }
+
+ final DuoOIDCIntegration integration = duoContext.getIntegration();
+ if (integration == null) {
+ log.error("{} No DuoOIDCIntegration available", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return;
+ }
+
+ final String username = duoContext.getUsername();
+ if (username == null) {
+ log.error("{} No username available", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return;
+ }
+
+ if (!authenticationContext.isResultCacheable() && requireResultCacheable) {
+ log.debug("{} Non-cacheable authentication, clearing guard cookie if set", getLogPrefix());
+ localManager.unsetCookie(localCookieName);
+ } else if (!integration.isPasswordless()) {
+ // Read in existing cookie, if any.
+ String cookie = localManager.getCookieValue(localCookieName, null);
+ if (cookie != null) {
+ cookie = URISupport.doURLDecode(cookie);
+ if (cookie != null) {
+ try {
+ final String unwrapped = localSealer.unwrap(cookie);
+ if (username.equals(unwrapped)) {
+ // The username is the same, so there's nothing to do, the flow should complete.
+ return;
+ } else {
+ // Clear the existing cookie to start fresh.
+ log.info("{} Clearing existing guard cookie for original username '{}'", getLogPrefix(),
+ unwrapped);
+ }
+ } catch (final DataSealerException e) {
+ log.warn("{} Unable to unwrap existing guard cookie", getLogPrefix(), e);
+ }
+ }
+ localManager.unsetCookie(localCookieName);
+ }
+
+ // This is a new user without an existing cookie set, so establish eligibility.
+ if (passwordlessCondition.test(profileRequestContext)) {
+ log.info("{} User '{}' eligible for passwordless, advancing to opt-in view", getLogPrefix(), username);
+ ActionSupport.buildEvent(profileRequestContext, PROMPT_USER_EVENT);
+ } else {
+ log.debug("{} User '{}' not eligible for passwordless", getLogPrefix(), username);
+ }
+ }
+ }
+ // Checkstyle: CyclomaticComplexity ON
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doPostExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ super.doPostExecute(profileRequestContext);
+
+ if (cleanupHook != null) {
+ cleanupHook.accept(profileRequestContext);
+ }
+ }
+
+}
\ 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 e64d8826..0bdef6ec 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
@@ -71,16 +71,19 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
/** Class logger.*/
@Nonnull private final Logger log = LoggerFactory.getLogger(ValidateDuoTokenAuthenticationResult.class);
-
- /** Duo authentiction context. */
- @NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
- /** The Duo integration.*/
- @NonnullBeforeExec private DuoOIDCIntegration duoIntegration;
+ /** Hook to map context information (often Duo factors in the Duo token) to principal collections.*/
+ @Nullable private Function<ProfileRequestContext,Collection<Principal>> contextToPrincipalMappingStrategy;
/** The profile request context.*/
@Nullable private ProfileRequestContext prc;
+ /** Duo authentiction context. */
+ @NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
+
+ /** The Duo integration.*/
+ @NonnullBeforeExec private DuoOIDCIntegration duoIntegration;
+
/** The parsed claimset. */
@NonnullBeforeExec private JWTClaimsSet claimsSet;
@@ -90,9 +93,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
/** Factor used. */
@Nullable private String factorUsed;
- /** Hook to map context information (often Duo factors in the Duo token) to principal collections.*/
- @Nullable private Function<ProfileRequestContext,Collection<Principal>> contextToPrincipalMappingStrategy;
-
/** Constructor.*/
public ValidateDuoTokenAuthenticationResult() {
setMetricName(DEFAULT_METRIC_NAME);
@@ -121,7 +121,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
contextToPrincipalMappingStrategy = hook;
}
-
/** {@inheritDoc} */
@Override protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
@@ -211,6 +210,8 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
if (DuoOIDCAuthAPI.DUO_AUTH_RESULT_ALLOW.equalsIgnoreCase(authResultStatus)){
factorUsed = extractFactor();
+ duoContext.setFactorUsed(factorUsed);
+
// Check if factor is allowed.
final Set<String> allowedFactors = duoIntegration.getAllowedFactors();
if (allowedFactors != null && !allowedFactors.contains(factorUsed)) {
@@ -277,7 +278,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
// Do nothing, just return null
}
return null;
-
}
/** {@inheritDoc} */
@@ -322,7 +322,8 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
}
/** {@inheritDoc} */
- @Override protected void buildAuthenticationResult(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Override
+ protected void buildAuthenticationResult(@Nonnull final ProfileRequestContext profileRequestContext,
@Nonnull final AuthenticationContext authenticationContext) {
super.buildAuthenticationResult(profileRequestContext, authenticationContext);
diff --git a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index a207f33f..f66adb1f 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -52,47 +52,4 @@
<bean id="shibboleth.DuoOIDCAuthnController"
class="net.shibboleth.idp.plugin.authn.duo.impl.DuoOIDCAuthnController" />
- <!-- Default Duo Admin API Integration for IdP-wide use. -->
- <bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" lazy-init="true"
- class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
- p:APIHost="%{idp.duo.oidc.admin.apiHost:%{idp.duo.oidc.apiHost:none}}"
- p:integrationKey="%{idp.duo.oidc.admin.integrationKey:none}"
- p:secretKey="%{idp.duo.oidc.admin.secretKey:none}"/>
- <bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant" lazy-init="true"
- c:target-ref="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" />
-
- <bean id="shibboleth.authn.DuoOIDC.DefaultAdminClient" class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoAdminClient"
- lazy-init="true"
- p:objectMapper-ref="shibboleth.JSONObjectMapper"
- p:httpClient="#{getObject('shibboleth.authn.DuoOIDC.Admin.HttpClient') ?: getObject('shibboleth.InternalHttpClient')}"
- p:httpClientSecurityParameters="#{getObject('shibboleth.authn.DuoOIDC.Admin.HttpClientSecurityParameters')}"
- p:adminDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.Admin.DuoIntegrationStrategy"
- p:backoffFactor="%{idp.duo.oidc.admin.backoffFactor:2}"
- p:initialBackoff="%{idp.duo.oidc.admin.initialBackoff:1000}"
- p:maxBackoff="%{idp.duo.oidc.admin.maxBackoff:16000}" />
-
- <!-- Default passwordless condition that uses the admin API -->
- <bean id="shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition" lazy-init="true"
- class="net.shibboleth.idp.plugin.authn.duo.DefaultPasswordlessEnrollmentCondition"
- p:duoAdminClient="#{getObject('shibboleth.authn.DuoOIDC.AdminClient') ?: getObject('shibboleth.authn.DuoOIDC.DefaultAdminClient')}"
- p:lockoutManager="#{%{idp.duo.oidc.passwordless.limitEnrollmentChecking:true} ? getObject('shibboleth.authn.DuoOIDC.Passwordless.LockoutManager') : null}">
- <property name="allowedLabels">
- <bean parent="shibboleth.CommaDelimStringArray"
- c:_0="#{'%{idp.duo.oidc.passwordless.allowedLabels:}'.trim()}" />
- </property>
- </bean>
-
- <bean id="shibboleth.authn.DuoOIDC.Passwordless.LockoutManager" lazy-init="true"
- parent="shibboleth.StorageBackedAccountLockoutManager"
- p:storageService-ref="#{'%{idp.duo.oidc.passwordless.enrollmentLockoutStorageService:shibboleth.StorageService}'.trim()}"
- p:maxAttempts="%{idp.duo.oidc.passwordless.limitEnrollmentChecking.maxAttempts:30}"
- p:counterInterval="%{idp.duo.oidc.passwordless.limitEnrollmentChecking.counterInterval:PT1M}"
- p:lockoutDuration="%{idp.duo.oidc.passwordless.limitEnrollmentChecking.lockoutDuration:PT2M}"
- p:extendLockoutDuration="false">
- <property name="lockoutKeyStrategy">
- <bean class="net.shibboleth.idp.plugin.authn.duo.DefaultPasswordlessEnrollmentCondition.IPLockoutKeyStrategy"
- p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
- </property>
- </bean>
-
</beans>
\ 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 286c8e28..b2c3f549 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
@@ -79,6 +79,15 @@
</bean>
<bean id="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
c:target="#{getObject('shibboleth.authn.DuoOIDC.Passwordless.DuoIntegration')}" />
+
+ <!-- Built-in condition to apply for factor-based passwordless authorization. -->
+ <bean id="shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition" lazy-init="true"
+ class="net.shibboleth.idp.plugin.authn.duo.DefaultPasswordlessCondition">
+ <property name="allowedFactors">
+ <bean parent="shibboleth.CommaDelimStringArray"
+ c:_0="#{'%{idp.duo.oidc.passwordless.allowedFactors:Platform authenticator (2fa)}'.trim()}" />
+ </property>
+ </bean>
<!-- Default "optional" non-browser integration. -->
<bean id="shibboleth.authn.DuoOIDC.NonBrowser.DuoIntegration" lazy-init="false"
@@ -97,7 +106,7 @@
<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
'shibboleth.authn.DuoOIDC.clientFactory' for it to be auto-loaded.
@@ -146,35 +155,17 @@
p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
<!-- Passwordless beans -->
- <bean id="CheckPasswordlessEnrollment2"
- class="net.shibboleth.idp.plugin.authn.duo.impl.CheckPasswordlessEnrollment" scope="prototype"
- p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
- p:passwordlessCondition="#{getObject('shibboleth.authn.DuoOIDC.Passwordless.Condition') ?: getObject('shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition')}"
- 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.duo.oidc.usernameFieldName:j_username}'.trim()}"
- p:lowercase="%{idp.duo.oidc.lowercase:false}"
- p:uppercase="%{idp.duo.oidc.uppercase:false}"
- p:trim="%{idp.duo.oidc.trim:true}"
- p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}"
- p:duoUsernameRemappingStrategy="#{getObject('shibboleth.authnn.DuoOIDC.UsernameRemappingStrategy')}"
- p:signalEvents="true"
- p:precedence="form" />
+ <bean id="PopulatePasswordlessContext" scope="prototype"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.PopulatePasswordlessContext"
+ p:dataSealer="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.DataSealer') : null}"
+ p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
+ p:cookieName="%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}" />
- <!-- Same bean as above but property-based enrollment check for first iteration of loop. -->
- <bean id="CheckPasswordlessEnrollment1" parent="CheckPasswordlessEnrollment2" scope="prototype"
- p:signalEvents="%{idp.duo.oidc.passwordless.alwaysSignal:false}">
- <property name="precedence">
- <bean parent="shibboleth.CommaDelimStringArray"
- c:_0="#{'%{idp.authn.usernamePrecedence:form,session,cookie}'.trim()}" />
- </property>
- </bean>
-
<!-- Duo OIDC beans -->
<bean id="PopulateDuoAuthenticationContext" scope="prototype"
class="net.shibboleth.idp.plugin.authn.duo.impl.PopulateDuoAuthenticationContext"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+ p:SSOBypassFieldName="%{idp.duo.oidc.passwordless.ssoBypassFieldName:donotcache}"
p:standardDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy"
p:passwordlessDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy"
p:redirectURICreationStrategy-ref="shibboleth.authn.DuoOIDC.RedirectURICreationStrategy"
@@ -299,15 +290,29 @@
<bean id="shibboleth.authn.DuoOIDC.DefaultCleanupHook" class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoCleanupHook" />
<bean id="ValidateDuoTokenAuthenticationResult" scope="prototype"
- class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult"
p:classifiedMessages="#{getObject('shibboleth.authn.DuoOIDC.ClassifiedMessageMap')}"
p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}"
- p:cleanupHook="#{getObject('shibboleth.authn.DuoOIDC.CleanUpHook') ?: getObject('shibboleth.authn.DuoOIDC.DefaultCleanupHook')}"
p:contextToPrincipalMappingStrategy="#{getObject('shibboleth.authn.DuoOIDC.ContextToPrincipalMappingStrategy')}"
p:addDefaultPrincipals="#{%{idp.authn.DuoOIDC.addDefaultPrincipals:true} and getObject('shibboleth.authn.DuoOIDC.ContextToPrincipalMappingStrategy') == null}"
p:populateAuditContextAction="#{%{idp.duo.oidc.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('PostDuoPopulateAuditContext') : null}"
p:writeAuditLogAction="#{%{idp.duo.oidc.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('WriteAuthnAuditLog') : null}" />
+ <bean id="PostValidatePasswordlessEvaluation"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.PostValidatePasswordlessEvaluation" scope="prototype"
+ p:passwordlessCondition-ref="#{%{idp.duo.oidc.passwordless.enabled:false} ? '%{idp.duo.oidc.passwordless.guardCondition:shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition}'.trim() : 'shibboleth.Conditions.FALSE'}"
+ p:cookieName="#{'%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}'.trim()}"
+ p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
+ p:dataSealer="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.DataSealer') : null}"
+ p:cleanupHook="#{getObject('shibboleth.authn.DuoOIDC.CleanUpHook') ?: getObject('shibboleth.authn.DuoOIDC.DefaultCleanupHook')}"
+ p:requireResultCacheable="%{idp.duo.oidc.passwordless.requireResultCacheable:true}" />
+
+ <bean id="CreatePasswordlessCookie"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.CreatePasswordlessCookie" scope="prototype"
+ p:cookieName="#{'%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}'.trim()}"
+ p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
+ p:dataSealer="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.DataSealer') : null}" />
+
<!-- Audit logging beans -->
<!--
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 ce310a0f..35332df7 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
@@ -12,18 +12,15 @@
<decision-state id="IsPasswordlessPossible">
<if test="!opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() and opensamlProfileRequestContext.isBrowserProfile()"
- then="CheckPasswordlessEnrollment1"
+ then="PopulatePasswordlessContext"
else="RequestUnsupported" />
</decision-state>
-
- <action-state id="CheckPasswordlessEnrollment1">
- <evaluate expression="CheckPasswordlessEnrollment1" />
+ <action-state id="PopulatePasswordlessContext">
+ <evaluate expression="PopulatePasswordlessContext" />
<evaluate expression="'proceed'" />
<transition on="proceed" to="PasswordlessView" />
- <transition on="UnknownUsername" to="PasswordlessView" />
- <transition on="RequestUnsupported" to="PasswordlessView" />
</action-state>
<view-state id="PasswordlessView" view="passwordless">
@@ -31,7 +28,7 @@
<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.ensureSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext)).getUsername()" result="viewScope.username" />
<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('DuoCSPDigester')" result="viewScope.cspDigester" />
@@ -41,19 +38,14 @@
<evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().containsBean('shibboleth.CustomViewContext') ? flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CustomViewContext') : null" result="viewScope.custom" />
</on-render>
- <transition on="proceed" to="CheckPasswordlessEnrollment2" />
+ <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
<transition on="cancel" to="RequestUnsupported" />
+
+ <on-exit>
+ <evaluate expression="opensamlProfileRequestContext.addSubcontext(new net.shibboleth.idp.consent.context.ConsentManagementContext(), true).setRevokeConsent(requestParameters._shib_idp_revokeConsent == 'true')" />
+ </on-exit>
</view-state>
- <action-state id="CheckPasswordlessEnrollment2">
- <evaluate expression="CheckPasswordlessEnrollment2" />
- <evaluate expression="'proceed'" />
-
- <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
- <transition on="UnknownUsername" to="PasswordlessView" />
- <transition on="RequestUnsupported" to="PasswordlessView" />
- </action-state>
-
<action-state id="ExtractDuoAuthenticationFromHeaders">
<evaluate expression="ExtractDuoAuthenticationFromHeaders" />
<evaluate expression="'proceed'" />
@@ -94,10 +86,10 @@
<transition on="proceed" to="ValidateDuoResponse" />
</view-state>
- <!-- match the response state to the request state, fail if error -->
+ <!-- match the response state to the request state, fail if error -->
<action-state id="ValidateDuoResponse">
<evaluate expression="ValidateExternalAuthenticationContext"/>
- <!-- Populate the audit context with the state before it is internall removed -->
+ <!-- Populate the audit context with the state before it is internally removed -->
<evaluate expression="PreStateValidationPopulateAuditContext" />
<evaluate expression="ValidateDuoResponseState"/>
<evaluate expression="'proceed'" />
@@ -112,10 +104,37 @@
<evaluate expression="ValidateTokenClaims"/>
<!-- final validation of the response status to build an authn result -->
<evaluate expression="ValidateDuoTokenAuthenticationResult"/>
+ <evaluate expression="PostValidatePasswordlessEvaluation"/>
<evaluate expression="'proceed'" />
<transition on="proceed" to="proceed" />
- </action-state>
+ <transition on="PasswordlessPrompt" to="PasswordlessOptIn"/>
+ </action-state>
+
+ <view-state id="PasswordlessOptIn" view="passwordless-optin">
+ <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.ui.context.RelyingPartyUIContext))" result="viewScope.rpUIContext" />
+ <evaluate expression="T(net.shibboleth.shared.codec.HTMLEncoder)" result="viewScope.encoder" />
+ <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('DuoCSPDigester')" result="viewScope.cspDigester" />
+ <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('DuoCSPNonce')" result="viewScope.cspNonce" />
+ <evaluate expression="flowRequestContext.getExternalContext().getNativeRequest()" result="viewScope.request" />
+ <evaluate expression="flowRequestContext.getExternalContext().getNativeResponse()" result="viewScope.response" />
+ <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().containsBean('shibboleth.CustomViewContext') ? flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CustomViewContext') : null" result="viewScope.custom" />
+ </on-render>
+
+ <transition on="proceed" to="CreatePasswordlessCookie" />
+ <transition on="cancel" to="proceed" />
+ </view-state>
+
+ <action-state id="CreatePasswordlessCookie">
+ <evaluate expression="CreatePasswordlessCookie"/>
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="proceed" />
+ </action-state>
<bean-import resource="duo-oidc-authn-beans.xml" />
</flow>
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
index db2f051b..9262969a 100644
--- a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
@@ -8,15 +8,15 @@
# in their own message files.
-idp.duo.passwordless.explain = If you've enrolled a passkey or device/token for passwordless login, please enter your \
- username below and press the corresponding button. To bypass this option, just press the alternate button to perform a \
- traditional login.
+idp.duo.passwordless.explain = If this isn't you, or you wish to bypass the option \
+ to use a Passkey or device to login, click the 'Login with Password' button.
idp.duo.passwordless.proceed = Login with Passkey or Device
idp.duo.passwordless.cancel = Login with Password
-idp.duo.passwordless.unsupported = The specified user has not enrolled a qualifying device for Passwordless use.
-idp.duo.passwordless.username = Please enter your username before attempting a Passwordless login.
-
idp.duo.enrollment = Enroll New Devices
idp.duo.enrollment.url = #
+
+idp.duo.passwordless.optin = Default to using your Passkey or device to login in the future?
+idp.duo.passwordless.optin.yes = Yes
+idp.duo.passwordless.optin.no = No
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
new file mode 100644
index 00000000..7501a29f
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
@@ -0,0 +1,62 @@
+##
+## Velocity Template for collection of username for Duo Passwordless use
+##
+## Velocity context will contain the following properties
+## flowExecutionUrl - the form action location
+## flowRequestContext - the Spring Web Flow RequestContext
+## flowExecutionKey - the SWF execution key (this is built into the flowExecutionUrl)
+## profileRequestContext - root of context tree
+## authenticationContext - context with authentication request information
+## encoder - HTMLEncoder class
+## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
+## cspNonce - Calculates secure nonces (call generateIdentifier)
+## request - HttpServletRequest
+## response - HttpServletResponse
+## environment - Spring Environment object for property resolution
+## custom - arbitrary object injected by deployer
+##
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>#springMessageText("idp.title", "Web Login Service")</title>
+ <meta charset="UTF-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
+ <link rel="stylesheet" type="text/css" href="$request.getContextPath()#springMessageText("idp.css", "/css/placeholder.css")">
+ </head>
+ <body>
+ <main class="main">
+ <header>
+ <img class="main-logo" src="$request.getContextPath()#springMessageText("idp.logo", "/images/placeholder-logo.png")" alt="#springMessageText("idp.logo.alt-text", "logo")" />
+ </header>
+
+ <section>
+ <form action="$flowExecutionUrl" method="post">
+ #parse("csrf/csrf.vm")
+
+ <p>#springMessageText("idp.duo.passwordless.optin", "Default to using your Passkey or device to login in the future?")</p>
+
+ <div class="grid">
+ <div class="grid-item">
+ <button type="submit" name="_eventId_proceed"
+ >#springMessageText("idp.duo.passwordless.optin.yes", "Yes")</button>
+ </div>
+ <div class="grid-item">
+ <button type="submit" name="_eventId_cancel"
+ >#springMessageText("idp.duo.passwordless.optin.no", "No")</button>
+ </div>
+ </div>
+ </form>
+
+ <ul>
+ <li><a href="#springMessageText('idp.url.helpdesk', '#')">#springMessageText("idp.login.needHelp", "Need Help?")</a></li>
+ </ul>
+ </section>
+ </main>
+ <footer class="footer">
+ <div class="cc">
+ <p>#springMessageText("idp.footer", "Insert your footer text here.")</p>
+ </div>
+ </footer>
+ </body>
+</html>
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
index 25eb3ca5..b8f70303 100644
--- a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
@@ -7,7 +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
+## username - Duo username
## rpUIContext - the context with SP UI information from the metadata
## encoder - HTMLEncoder class
## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
@@ -18,11 +18,6 @@
## custom - arbitrary object injected by deployer
##
#set ($rpContext = $profileRequestContext.getSubcontext('net.shibboleth.profile.context.RelyingPartyContext'))
-#set ($username = $passwordlessContext.getUsername())
-#set ($eventCtx = $profileRequestContext.getSubcontext('org.opensaml.profile.context.EventContext'))
-#if ($eventCtx)
-#set ($eventId = $eventCtx.getEvent())
-#end
#set ($nonce = $cspNonce.generateIdentifier())
$response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
##
@@ -67,28 +62,21 @@ $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
<p>$encoder.encodeForHTML($desc)</p>
#end
- <blockquote>#springMessageText("idp.duo.passwordless.explain", "If you've enrolled a passkey or device/token for passwordless login,
- please enter your username below and press the corresponding button. To bypass this option, just press the alternate button
- to perform a traditional login.")</blockquote>
-
- #if ($eventId == "RequestUnsupported")
- <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.unsupported', 'The specified user has not enrolled a qualifying device for Passwordless use.')")</p>
- #elseif ($eventId == "UnknownUsername")
- <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.username', 'Please enter your username before attempting a Passwordless login.')")</p>
- #end
-
<form action="$flowExecutionUrl" method="post">
#parse("csrf/csrf.vm")
<label for="username">#springMessageText("idp.login.username", "Username")</label>
- <input id="j_username" name="j_username" type="text"
- value="#if($username)$encoder.encodeForHTML($username)#end" />
-
+ <input id="unused" name="unused" type="text" readonly="true" value="$encoder.encodeForHTML($username)" />
+
+ <p>#springMessageText("idp.duo.passwordless.explain", "If this isn't you, or you wish to bypass the option
+ to use a Passkey or device to login, click the 'Login with Password' button.")</p>
+
<input type="checkbox" name="donotcache" value="1" id="donotcache" />
<label for="donotcache">#springMessageText("idp.login.donotcache", "Don't Remember Login")</label>
<input id="_shib_idp_revokeConsent" type="checkbox" name="_shib_idp_revokeConsent" value="true" />
- <label for="_shib_idp_revokeConsent">#springMessageText("idp.attribute-release.revoke", "Clear prior granting of permission for release of your information to this service.")</label>
+ <label for="_shib_idp_revokeConsent">#springMessageText("idp.attribute-release.revoke", "Clear prior granting of permission for
+ release of your information to this service.")</label>
<div class="grid">
<div class="grid-item">
@@ -96,7 +84,7 @@ $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
>#springMessageText("idp.duo.passwordless.proceed", "Login with Passkey or Device")</button>
</div>
<div class="grid-item">
- <button type="submit" name="_eventId_cancel" onClick="$onClick"
+ <button type="submit" name="_eventId_cancel"
>#springMessageText("idp.duo.passwordless.cancel", "Login with Password")</button>
</div>
</div>
@@ -116,7 +104,7 @@ $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
<script #if ($nonce)nonce="$nonce"#end>
<!--
- const input = document.getElementById('j_username');
+ const input = document.getElementById('unused');
const end = input.value.length;
input.setSelectionRange(end, end);
input.focus();
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
index dfcb4743..33d8c2e3 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
@@ -458,12 +458,6 @@ public abstract class AbstractAuthnXmlFlowExecutionTests extends CustomAbstractX
// Add bean to allow runtime exceptions to be logged (rather than a stack overflow).
addBeanDefinition(builderContext, "LogRuntimeException",BeanDefinitionBuilder.
genericBeanDefinition(net.shibboleth.idp.profile.LogRuntimeException.class).getBeanDefinition());
-
- addBeanDefinition(builderContext, "shibboleth.StorageService",BeanDefinitionBuilder.
- genericBeanDefinition(MemoryStorageService.class).setInitMethodName("initialize").getBeanDefinition());
-
- addBeanDefinition(builderContext, "shibboleth.StorageBackedAccountLockoutManager",BeanDefinitionBuilder.
- genericBeanDefinition(StorageBackedAccountLockoutManager.class).setAbstract(true).getBeanDefinition());
if (clientFactory != null) {
//register the client factory
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollmentTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollmentTest.java
deleted file mode 100644
index 1a5eeaa4..00000000
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollmentTest.java
+++ /dev/null
@@ -1,348 +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.time.Instant;
-import java.util.Set;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import javax.security.auth.Subject;
-
-import org.springframework.core.io.ClassPathResource;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-import org.springframework.webflow.execution.Event;
-import org.testng.Assert;
-import org.testng.annotations.AfterMethod;
-import org.testng.annotations.BeforeClass;
-import org.testng.annotations.BeforeMethod;
-import org.testng.annotations.Test;
-
-import com.google.common.net.UrlEscapers;
-
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import net.shibboleth.idp.authn.AuthenticationResult;
-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.plugin.authn.util.mock.TestResourceConverter;
-import net.shibboleth.idp.profile.testing.ActionTestingSupport;
-import net.shibboleth.idp.session.IdPSession;
-import net.shibboleth.idp.session.SPSession;
-import net.shibboleth.idp.session.SessionException;
-import net.shibboleth.idp.session.context.SessionContext;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.collection.CollectionSupport;
-import net.shibboleth.shared.component.ComponentInitializationException;
-import net.shibboleth.shared.net.CookieManager;
-import net.shibboleth.shared.net.URISupport;
-import net.shibboleth.shared.security.DataSealer;
-import net.shibboleth.shared.security.DataSealerException;
-import net.shibboleth.shared.security.impl.BasicKeystoreKeyStrategy;
-import net.shibboleth.shared.servlet.impl.HttpServletRequestResponseContext;
-import net.shibboleth.shared.servlet.impl.ThreadLocalHttpServletRequestSupplier;
-import net.shibboleth.shared.servlet.impl.ThreadLocalHttpServletResponseSupplier;
-
-/** {@link CheckPasswordlessEnrollment} unit test. */
- at SuppressWarnings("javadoc")
-public class CheckPasswordlessEnrollmentTest extends AbstractDuoActionTest {
-
- @Nonnull @NotEmpty public static final String COOKIE_NAME = "_shib_idp_username";
-
- private DataSealer dataSealer;
-
- private CookieManager cookieManager;
-
- private CheckPasswordlessEnrollment action;
-
- @BeforeClass public void init() throws ComponentInitializationException {
-
- final BasicKeystoreKeyStrategy strategy = new BasicKeystoreKeyStrategy();
-
- strategy.setKeyAlias("secret");
- strategy.setKeyPassword("kpassword");
- strategy.setKeystorePassword("password");
- strategy.setKeystoreResource(TestResourceConverter.of(
- new ClassPathResource("net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.jks")));
- strategy.setKeyVersionResource(TestResourceConverter.of(
- new ClassPathResource("net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.kver")));
- strategy.initialize();
-
- dataSealer = new DataSealer();
- dataSealer.setKeyStrategy(strategy);
- dataSealer.initialize();
-
- cookieManager = new CookieManager();
- cookieManager.setHttpServletRequestSupplier(new ThreadLocalHttpServletRequestSupplier());
- cookieManager.setHttpServletResponseSupplier(new ThreadLocalHttpServletResponseSupplier());
- cookieManager.setCookiePath("/");
- cookieManager.setMaxAge(300);
- cookieManager.initialize();
- }
-
- @BeforeMethod public void setUp() throws ComponentInitializationException {
- super.setup();
-
- ac.ensureSubcontext(DuoPasswordlessContext.class);
-
- HttpServletRequestResponseContext.loadCurrent(new MockHttpServletRequest(), new MockHttpServletResponse());
-
- action = new CheckPasswordlessEnrollment();
- action.setPasswordlessCondition((a,b) -> {return true;});
- action.setDataSealer(dataSealer);
- action.setCookieManager(cookieManager);
- action.setCookieName(COOKIE_NAME);
- action.setHttpServletRequestSupplier(new ThreadLocalHttpServletRequestSupplier());
- action.setHttpServletResponseSupplier(new ThreadLocalHttpServletResponseSupplier());
- action.setSignalEvents(true);
- action.initialize();
- }
-
- @AfterMethod public void tearDown() {
- HttpServletRequestResponseContext.clearCurrent();
- }
-
- @Test public void testNoServlet() throws ComponentInitializationException {
- action = new CheckPasswordlessEnrollment();
- action.setSignalEvents(true);
- action.initialize();
- final Event event = action.execute(src);
-
- ActionTestingSupport.assertEvent(event, AuthnEventIds.UNKNOWN_USERNAME);
- }
-
- @Test public void testMissingIdentity() {
- final Event event = action.execute(src);
- ActionTestingSupport.assertEvent(event, AuthnEventIds.UNKNOWN_USERNAME);
- }
-
- @Test public void testUnchangedIdentity() {
- ac.ensureSubcontext(DuoPasswordlessContext.class).setUsername("bar");
-
- final Event event = action.execute(src);
- ActionTestingSupport.assertEvent(event, AuthnEventIds.REQUEST_UNSUPPORTED);
- }
-
- @Test public void testUnchangedIdentityEnrolled() {
- ac.ensureSubcontext(DuoPasswordlessContext.class).setUsername("bar").setEnrolled(true);
-
- final Event event = action.execute(src);
- ActionTestingSupport.assertProceedEvent(event);
- }
-
- @Test public void testFromForm() {
- ensureMockRequest().addParameter("j_username", "foo");
-
- final Event event = action.execute(src);
- ActionTestingSupport.assertProceedEvent(event);
- final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
- Assert.assertTrue(authCtx.isResultCacheable());
- final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
- Assert.assertEquals(duoCtx.getUsername(), "foo");
- Assert.assertTrue(duoCtx.isEnrolled());
- }
-
- @Test public void testSSOBypass() {
- ensureMockRequest().addParameter("j_username", "foo");
- ensureMockRequest().addParameter("donotcache", "1");
-
- final Event event = action.execute(src);
- ActionTestingSupport.assertProceedEvent(event);
- final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
- Assert.assertFalse(authCtx.isResultCacheable());
- final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
- Assert.assertEquals(duoCtx.getUsername(), "foo");
- Assert.assertTrue(duoCtx.isEnrolled());
-
- final Cookie cookie = ensureMockResponse().getCookie(COOKIE_NAME);
- assert cookie != null;
- Assert.assertNull(cookie.getValue());
- Assert.assertEquals(cookie.getMaxAge(), 0);
- }
-
- @Test public void testFromCookie() throws DataSealerException {
- // Wrong field name.
- ensureMockRequest().addParameter("username", "foo");
-
- final String wrapped = dataSealer.wrap("foo");
- final Cookie cookie = new Cookie(COOKIE_NAME, UrlEscapers.urlFormParameterEscaper().escape(wrapped));
- ensureMockRequest().setCookies(cookie);
-
- final Event event = action.execute(src);
- ActionTestingSupport.assertProceedEvent(event);
- final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
- Assert.assertTrue(authCtx.isResultCacheable());
- final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
- Assert.assertEquals(duoCtx.getUsername(), "foo");
- Assert.assertTrue(duoCtx.isEnrolled());
-
- final Cookie cookie2 = ensureMockResponse().getCookie(COOKIE_NAME);
- assert cookie2 != null;
- Assert.assertEquals(cookie2.getMaxAge(), 300);
- String unwrapped = URISupport.doURLDecode(cookie2.getValue());
- if (unwrapped != null) {
- unwrapped = dataSealer.unwrap(unwrapped);
- }
- Assert.assertEquals(unwrapped, "foo");
- }
-
- @Test public void testFromSession() throws DataSealerException {
- // Wrong field name.
- ensureMockRequest().addParameter("username", "foo");
-
- // Wrong cookie name.
- final String wrapped = dataSealer.wrap("foo");
- final Cookie cookie = new Cookie(COOKIE_NAME + "1", UrlEscapers.urlFormParameterEscaper().escape(wrapped));
- ensureMockRequest().setCookies(cookie);
-
- prc.ensureSubcontext(SessionContext.class).setIdPSession(new MockIdPSession());
- ac.getActiveResults().put("foo", new AuthenticationResult("foo", new Subject()));
-
- final Event event = action.execute(src);
- ActionTestingSupport.assertProceedEvent(event);
- final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
- Assert.assertTrue(authCtx.isResultCacheable());
- final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
- Assert.assertEquals(duoCtx.getUsername(), "foo");
- Assert.assertTrue(duoCtx.isEnrolled());
-
- final Cookie cookie2 = ensureMockResponse().getCookie(COOKIE_NAME);
- assert cookie2 != null;
- Assert.assertEquals(cookie2.getMaxAge(), 300);
- String unwrapped = URISupport.doURLDecode(cookie2.getValue());
- if (unwrapped != null) {
- unwrapped = dataSealer.unwrap(unwrapped);
- }
- Assert.assertEquals(unwrapped, "foo");
- }
-
- @Nonnull private MockHttpServletRequest ensureMockRequest() {
- final HttpServletRequest request = HttpServletRequestResponseContext.getRequest();
- return MockHttpServletRequest.class.cast(request);
- }
-
- @Nonnull private MockHttpServletResponse ensureMockResponse() {
- final HttpServletResponse request = HttpServletRequestResponseContext.getResponse();
- return MockHttpServletResponse.class.cast(request);
- }
-
- private class MockIdPSession implements IdPSession {
-
- /** {@inheritDoc} */
- @Override
- @Nullable
- public String getId() {
- return "id";
- }
-
- /** {@inheritDoc} */
- @Override
- @Nonnull
- public String getPrincipalName() {
- return "foo";
- }
-
- /** {@inheritDoc} */
- @Override
- @Nonnull
- public Instant getCreationInstant() {
- return Instant.now();
- }
-
- /** {@inheritDoc} */
- @Override
- @Nonnull
- public Instant getLastActivityInstant() {
- return Instant.now();
- }
-
- /** {@inheritDoc} */
- @Override
- public boolean checkAddress(@Nonnull String address) throws SessionException {
- return false;
- }
-
- /** {@inheritDoc} */
- @Override
- public boolean checkTimeout() throws SessionException {
- return false;
- }
-
- /** {@inheritDoc} */
- @Override
- @Nonnull
- public Set<AuthenticationResult> getAuthenticationResults() {
- return CollectionSupport.emptySet();
- }
-
- /** {@inheritDoc} */
- @Override
- @Nullable
- public AuthenticationResult getAuthenticationResult(@Nonnull String flowId) {
- return null;
- }
-
- /** {@inheritDoc} */
- @Override
- @Nullable
- public AuthenticationResult addAuthenticationResult(@Nonnull AuthenticationResult result)
- throws SessionException {
- return null;
- }
-
- /** {@inheritDoc} */
- @Override
- public void updateAuthenticationResultActivity(@Nonnull AuthenticationResult result) throws SessionException {
- }
-
- /** {@inheritDoc} */
- @Override
- public boolean removeAuthenticationResult(@Nonnull AuthenticationResult result) throws SessionException {
- return false;
- }
-
- /** {@inheritDoc} */
- @Override
- @Nonnull
- public Set<SPSession> getSPSessions() {
- return CollectionSupport.emptySet();
- }
-
- /** {@inheritDoc} */
- @Override
- @Nullable
- public SPSession getSPSession(@Nonnull String serviceId) {
- return null;
- }
-
- /** {@inheritDoc} */
- @Override
- @Nullable
- public SPSession addSPSession(@Nonnull SPSession spSession) throws SessionException {
- return null;
- }
-
- /** {@inheritDoc} */
- @Override
- public boolean removeSPSession(@Nonnull SPSession spSession) throws SessionException {
- return false;
- }
- }
-
-}
\ No newline at end of file
diff --git a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
index 05389cae..e9be1df0 100644
--- a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
+++ b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
@@ -36,12 +36,16 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
#idp.authn.DuoOIDC.addDefaultPrincipals = true
# Passwordless integration if desired
+#idp.duo.oidc.passwordless.enabled = false
#idp.duo.oidc.passwordless.apiHost = %{idp.duo.oidc.apiHost}
-#idp.duo.oidc.passwordless.integrationKey = ikey
-# Suggest defining this in credentials/secrets.properties
+#idp.duo.oidc.passwordless.clientId = clientId
+# We suggest defining this in credentials/secrets.properties
#idp.duo.oidc.passwordless.secretKey = key
-# Controls whether to report enrollment check on entry to form
-#idp.duo.oidc.passwordless.alwaysSignal = false
+#idp.duo.oidc.passwordless.ssoBypassFieldName = donotcache
+# Name of cookie used to "grant" access to passwordless login option
+#idp.duo.oidc.passwordless.guardCookieName = __Host-shib_idp_duo_passwordless
+# Override to plug in your own condition bean governing eligibility.
+#idp.duo.oidc.passwordless.guardCondition = (internal default)
# Non-Browser AuthAPI integration if desired
#idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}
diff --git a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties
index 9e090a54..0a66858c 100644
--- a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties
+++ b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties
@@ -15,3 +15,5 @@ idp.authn.DuoOIDC.2.src = /net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn
idp.authn.DuoOIDC.2.dest = conf/authn/duo-oidc.properties
idp.authn.DuoOIDC.3.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
idp.authn.DuoOIDC.3.dest = views/passwordless.vm
+idp.authn.DuoOIDC.4.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
+idp.authn.DuoOIDC.4.dest = views/passwordless-optin.vm
diff --git a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
index c05549cf..212b5335 100644
--- a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
+++ b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
@@ -36,12 +36,16 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
#idp.authn.DuoOIDC.addDefaultPrincipals = true
# Passwordless integration if desired
+#idp.duo.oidc.passwordless.enabled = false
#idp.duo.oidc.passwordless.apiHost = %{idp.duo.oidc.apiHost}
-#idp.duo.oidc.passwordless.integrationKey = ikey
-# Suggest defining this in credentials/secrets.properties
+#idp.duo.oidc.passwordless.clientId = clientId
+# We suggest defining this in credentials/secrets.properties
#idp.duo.oidc.passwordless.secretKey = key
-# Controls whether to report enrollment check on entry to form
-#idp.duo.oidc.passwordless.alwaysSignal = false
+#idp.duo.oidc.passwordless.ssoBypassFieldName = donotcache
+# Name of cookie used to "grant" access to passwordless login option
+#idp.duo.oidc.passwordless.guardCookieName = __Host-shib_idp_duo_passwordless
+# Override to plug in your own condition bean governing eligibility.
+#idp.duo.oidc.passwordless.guardCondition = (internal default)
# Non-Browser AuthAPI integration if desired
#idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}
diff --git a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties
index 96865df4..36a21ef7 100644
--- a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties
+++ b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties
@@ -15,3 +15,5 @@ idp.authn.DuoOIDC.2.src = /net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/du
idp.authn.DuoOIDC.2.dest = conf/authn/duo-oidc.properties
idp.authn.DuoOIDC.3.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
idp.authn.DuoOIDC.3.dest = views/passwordless.vm
+idp.authn.DuoOIDC.4.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
+idp.authn.DuoOIDC.4.dest = views/passwordless-optin.vm
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list