[java-idp-plugin-duo] branch main updated: JDUO-80 - Use of Duo as a passwordless solution
Scott Cantor
cantor.2 at osu.edu
Tue Apr 9 14:41:07 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=017d032022ab7fa0b74e64c37980f30a6e51b56c
The following commit(s) were added to refs/heads/main by this push:
new 017d0320 JDUO-80 - Use of Duo as a passwordless solution
017d0320 is described below
commit 017d032022ab7fa0b74e64c37980f30a6e51b56c
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Apr 9 10:41:04 2024 -0400
JDUO-80 - Use of Duo as a passwordless solution
https://shibboleth.atlassian.net/browse/JDUO-80
Third revamp, adjusting event signalling.
Properly track opt-out to avoid nagging.
Added new API for managing cookie for easier use by deployers.
Allow bypass of cookie and view by MFA flow.
---
.../authn/duo/PasswordlessCookieManager.java | 247 +++++++++++++++++++++
.../authn/duo/context/DuoPasswordlessContext.java | 24 ++
.../authn/duo/impl/ClearPasswordlessCookie.java | 51 ++---
.../authn/duo/impl/CreatePasswordlessCookie.java | 100 +++++----
.../duo/impl/PopulateDuoAuthenticationContext.java | 6 +-
.../duo/impl/PopulatePasswordlessContext.java | 97 +++-----
.../impl/PostValidatePasswordlessEvaluation.java | 99 +++------
.../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml | 26 +--
.../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml | 13 +-
.../plugin/authn/duo/views/passwordless-optin.vm | 8 +-
10 files changed, 437 insertions(+), 234 deletions(-)
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessCookieManager.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessCookieManager.java
new file mode 100644
index 00000000..999559a6
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessCookieManager.java
@@ -0,0 +1,247 @@
+/*
+ * 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 javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+
+import com.google.common.net.UrlEscapers;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+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;
+
+/**
+ * Wrapper for managing the passwordless guard cookie, allowing read/write with less explicit code,
+ * error handling, etc.
+ *
+ * <p>The component can be wired up without the necessary components, but then all operations do nothing.
+ * This is allowed for the case where deployers disable the shared key feature of the IdP, though very rare.</p>
+ *
+ * @since 2.1.0
+ */
+public class PasswordlessCookieManager extends AbstractInitializableComponent {
+
+ /** A negative signal to allow caching opt-out. */
+ @Nonnull @NotEmpty private static final String NEGATIVE_VALUE = "__NO";
+
+ /** Class logger.*/
+ @Nonnull private final Logger log = LoggerFactory.getLogger(PasswordlessCookieManager.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;
+
+ /** Flags whether the component is active or should no-op. */
+ private boolean active;
+
+ /**
+ * 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 doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ active = cookieName != null && cookieManager != null && dataSealer != null;
+ }
+
+ /**
+ * Tests whether the cookie's value indicates a cached negative response.
+ *
+ * @return true iff the input value corresponds to the "opt-out" constant
+ */
+ public boolean isOptOut() {
+ checkComponentActive();
+
+ if (!active) {
+ return true;
+ }
+
+ assert cookieManager != null;
+ assert cookieName != null;
+ final String value = cookieManager.getCookieValue(cookieName, null);
+
+ return NEGATIVE_VALUE.equals(value);
+ }
+
+ /**
+ * Read back existing cookie and return the username embedded in it, if any.
+ *
+ * <p>A null is returned in the event of various decoding errors or if the cookie
+ * contains the "negative" magic value.</p>
+ *
+ * @return username from sealed cookie, or null
+ */
+ @Nullable @NotEmpty public String readCookie() {
+ checkComponentActive();
+
+ if (!active) {
+ return null;
+ }
+
+ assert cookieManager != null;
+ assert cookieName != null;
+ String wrapped = cookieManager.getCookieValue(cookieName, null);
+ if (wrapped == null) {
+ return null;
+ }
+
+ if (NEGATIVE_VALUE.equals(wrapped)) {
+ return null;
+ }
+
+ wrapped = URISupport.doURLDecode(wrapped);
+ if (wrapped == null) {
+ log.error("Error decoding unwrapped cookie value");
+ return null;
+ }
+
+ try {
+ assert dataSealer != null;
+ return dataSealer.unwrap(wrapped);
+ } catch (final DataSealerException e) {
+ log.warn("Unable to unwrap sealed cookie", e);
+ }
+
+ return null;
+ }
+
+ /**
+ * Creates a fresh cookie for a given username (or a placeholder if null to indicate the negative).
+ *
+ * <p>TODO: Notably the second parameter is currently unimplemented until a version of the plugin with the correct
+ * API dependency is released.</p>
+ *
+ * @param username username or null
+ * @param maxAge lifetime of the cookie
+ *
+ * @return true iff the operation succeeded
+ */
+ public boolean writeCookie(@Nullable final String username, final int maxAge) {
+ checkComponentActive();
+
+ if (!active) {
+ return false;
+ }
+
+ if (username == null || NEGATIVE_VALUE.equals(username)) {
+ assert cookieManager != null;
+ assert cookieName != null;
+ cookieManager.addCookie(cookieName, NEGATIVE_VALUE);
+ return true;
+ }
+
+ try {
+ assert dataSealer != null;
+ String wrapped = dataSealer.wrap(username);
+ wrapped = UrlEscapers.urlFormParameterEscaper().escape(wrapped);
+ assert cookieManager != null;
+ assert cookieName != null;
+ assert wrapped != null;
+ cookieManager.addCookie(cookieName, wrapped);
+ return true;
+ } catch (final DataSealerException e) {
+ log.warn("Unable to wrap username for cookie", e);
+ return false;
+ }
+ }
+
+ /**
+ * For a non-negative cookie, this recreates the cookie using the current default key to ensure it can
+ * continue to be read.
+ *
+ * <p>TODO: Notably the second parameter is currently unimplemented until a version of the plugin with the correct
+ * API dependency is released.</p>
+ *
+ * @param maxAge lifetime of the cookie
+ *
+ * @return true iff the operation succeeded
+ */
+ public boolean refreshCookie(final int maxAge) {
+ checkComponentActive();
+
+ if (!active) {
+ return false;
+ }
+
+ final String username = readCookie();
+ if (username != null) {
+ return writeCookie(username, maxAge);
+ }
+
+ return true;
+ }
+
+ /**
+ * Unset the cookie.
+ */
+ public void clearCookie() {
+ checkComponentActive();
+
+ if (!active) {
+ return;
+ }
+
+ assert cookieManager != null;
+ assert cookieName != null;
+ cookieManager.unsetCookie(cookieName);
+ }
+
+
+}
\ 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 114e5d52..8f75d0cc 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
@@ -38,6 +38,9 @@ public final class DuoPasswordlessContext extends BaseContext {
/** Username. */
@Nullable private String username;
+ /** Whether to bypass the passwordless view directly into Duo. */
+ private boolean bypassView;
+
/**
* Get the username.
*
@@ -59,4 +62,25 @@ public final class DuoPasswordlessContext extends BaseContext {
return this;
}
+ /**
+ * Get whether to bypass the view in favor of directly invoking the service.
+ *
+ * @return true iff the view should be bypassed
+ */
+ public boolean isBypassView() {
+ return bypassView;
+ }
+
+ /**
+ * Set whether to bypass the view in favor of directly invoking the service.
+ *
+ * @param flag flag to set
+ *
+ * @return this context
+ */
+ @Nonnull public DuoPasswordlessContext setBypassView(final boolean flag) {
+ bypassView = flag;
+ return this;
+ }
+
}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java
index dc8eaaa1..d022a6b2 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java
@@ -17,18 +17,16 @@ 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 net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager;
import net.shibboleth.idp.profile.AbstractProfileAction;
-import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
-import net.shibboleth.shared.primitive.StringSupport;
/**
* A profile action to clear the passwordless guard cookie.
@@ -42,49 +40,29 @@ public class ClearPasswordlessCookie extends AbstractProfileAction {
/** Class logger. */
@Nonnull private final Logger log = LoggerFactory.getLogger(ClearPasswordlessCookie.class);
-
- /** Passwordless cookie name. */
- @NonnullBeforeExec @NotEmpty private String cookieName;
/** Optional cookie manager to use. */
- @NonnullBeforeExec private CookieManager cookieManager;
-
- /**
- * 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);
- }
+ @NonnullAfterInit private PasswordlessCookieManager cookieManager;
/**
- * Sets optional {@link CookieManager} to use.
+ * Sets {@link PasswordlessCookieManager} to use.
*
* @param manager cookie manager
*/
- public void setCookieManager(@Nullable final CookieManager manager) {
+ public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
checkSetterPreconditions();
- cookieManager = manager;
+ cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
}
-
+
/** {@inheritDoc} */
@Override
- protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
- if (!super.doPreExecute(profileRequestContext)) {
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
- return false;
- }
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
- if (cookieName == null || cookieManager == null) {
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
- return false;
+ if (cookieManager == null) {
+ throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
}
-
- return true;
}
/** {@inheritDoc} */
@@ -93,8 +71,7 @@ public class ClearPasswordlessCookie extends AbstractProfileAction {
log.debug("{} Clearing passwordless guard cookie at user instruction", getLogPrefix());
- assert cookieName != null;
- cookieManager.unsetCookie(cookieName);
+ cookieManager.clearCookie();
}
}
\ 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
index 61ff2ed2..364e0914 100644
--- 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
@@ -22,22 +22,25 @@ 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.AuthnEventIds;
import net.shibboleth.idp.authn.context.SubjectCanonicalizationContext;
+import net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager;
import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
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.
*
+ * <p>A form field indicates whether an opt-in or opt-out is to be created.</p>
+ *
+ * <p>TODO: In a future release the cookie lifetimes will be controllable.</p>
+ *
* @event {@link EventIds#PROCEED_EVENT_ID}
* @event {@link AuthnEventIds#INVALID_SUBJECT_C14N_CTX}
*
@@ -49,62 +52,67 @@ import net.shibboleth.shared.security.DataSealerException;
*/
public class CreatePasswordlessCookie extends AbstractProfileAction {
+ /** Name of form field signalling yes/no to opt-in. */
+ @Nonnull @NotEmpty public static final String OPTIN_FIELD_NAME = "optin";
+
/** 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;
+ @NonnullAfterInit private PasswordlessCookieManager cookieManager;
- /**
- * 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);
+ /** Max-Age of opt-in cookie. */
+ private int optInMaxAge;
+
+ /** Max-Age of opt-out cookie. */
+ private int optOutMaxAge;
+
+ /** Constructor. */
+ public CreatePasswordlessCookie() {
+ optInMaxAge = 0;
+ optOutMaxAge = 0;
}
-
+
/**
- * Sets {@link CookieManager} to use.
+ * Sets {@link PasswordlessCookieManager} to use.
*
* @param manager cookie manager
*/
- public void setCookieManager(@Nullable final CookieManager manager) {
+ public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
checkSetterPreconditions();
- cookieManager = manager;
+ cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
}
-
- /**
- * Sets {@link DataSealer} to use.
- *
- * @param sealer data sealer
- */
- public void setDataSealer(@Nullable final DataSealer sealer) {
- checkSetterPreconditions();
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
- dataSealer = sealer;
+ if (cookieManager == null) {
+ throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
+ }
}
/** {@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");
+
+ final String optin;
+ final HttpServletRequest request = getHttpServletRequest();
+ if (request == null) {
+ log.error("{} HttpServletRequest absent, assuming opt-out", getLogPrefix());
+ optin = "0";
+ } else {
+ optin = request.getParameter(OPTIN_FIELD_NAME);
+ }
+
+ if (!"1".equals(optin)) {
+ if (!cookieManager.writeCookie(null, optOutMaxAge)) {
+ log.warn("{} Failed to create passwordless cookie for opt-out", getLogPrefix());
+ }
return;
}
-
+
final SubjectCanonicalizationContext c14nContext =
profileRequestContext.getSubcontext(SubjectCanonicalizationContext.class);
final String username = c14nContext != null ? c14nContext.getPrincipalName() : null;
@@ -114,11 +122,9 @@ public class CreatePasswordlessCookie extends AbstractProfileAction {
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);
+ log.debug("{} Creating passwordless cookie for username '{}'", getLogPrefix(), username);
+ if (!cookieManager.writeCookie(username, optInMaxAge)) {
+ log.warn("{} Failed to create passwordless cookie for username '{}'", getLogPrefix(), username);
}
}
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 98898b3c..6d148017 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
@@ -62,7 +62,7 @@ import net.shibboleth.shared.primitive.StringSupport;
*
* @event {@link EventIds#PROCEED_EVENT_ID}
* @event {@link EventIds#INVALID_PROFILE_CTX}
- * @event {@link AuthnEventIds#NO_CREDENTIALS}
+ * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
* @event {@link AuthnEventIds#AUTHN_EXCEPTION}
* @post See above.
*/
@@ -279,7 +279,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
if (passwordlessContext.getUsername() == null) {
log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
return false;
}
duoContext.setUsername(passwordlessContext.getUsername());
@@ -331,7 +331,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
final String username = usernameLookupStrategy.apply(profileRequestContext);
if (username == null) {
log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
return false;
}
duoContext.setUsername(username);
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
index 88a9eb0d..6e3e3e5b 100644
--- 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
@@ -27,29 +27,30 @@ import org.slf4j.Logger;
import net.shibboleth.idp.authn.AuthnEventIds;
import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager;
import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.ComponentInitializationException;
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 and populate it
+ * A profile action to extract passwordless username from a sealed cookie and populate it
* into an existing {@link DuoPasswordlessContext}.
*
- * <p>If the context contains a username already, the action simply exits.</p>
+ * <p>If the context contains a username already, the action simply proceeds.</p>
+ *
+ * <p>If the cookie exists but signals an opt-out, then the {@link AuthnEventIds#RESELECT_FLOW}
+ * event is signalled.</p>
*
* @since 2.1.0
*
* @event {@link EventIds#PROCEED_EVENT_ID}
* @event {@link EventIds#INVALID_PROFILE_CTX}
* @event {@link AuthnEventIds#REQUEST_UNSUPPORTED}
+ * @event {@link AuthnEventIds#RESELECT_FLOW}
* @pre <pre>AuthenticationContext.getSubcontext(DuoPasswordlessContext.class) != null</pre>
* @post <pre>DuoPasswordlessContext.getUsername() != null</pre>
*/
@@ -60,15 +61,9 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
/** 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;
+ @NonnullAfterInit private PasswordlessCookieManager cookieManager;
/** Context to populate. */
@NonnullBeforeExec private DuoPasswordlessContext passwordlessContext;
@@ -95,36 +90,24 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
}
/**
- * 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.
+ * Sets {@link PasswordlessCookieManager} to use.
*
* @param manager cookie manager
*/
- public void setCookieManager(@Nullable final CookieManager manager) {
+ public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
checkSetterPreconditions();
- cookieManager = manager;
+ cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
}
-
- /**
- * Sets optional {@link DataSealer} to use.
- *
- * @param sealer data sealer
- */
- public void setDataSealer(@Nullable final DataSealer sealer) {
- checkSetterPreconditions();
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
- dataSealer = sealer;
+ if (cookieManager == null) {
+ throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
+ }
}
/** {@inheritDoc} */
@@ -135,11 +118,6 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
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);
@@ -152,37 +130,28 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
/** {@inheritDoc} */
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
-
- final String existingUsername = passwordlessContext.getUsername();
- if (existingUsername != null) {
- log.debug("Existing username '{}' in context left in place", getLogPrefix(), existingUsername);
+
+ if (passwordlessContext.getUsername() != null) {
+ log.debug("{} Pre-existing username for passwordless authentication: {}", getLogPrefix(),
+ passwordlessContext.getUsername());
return;
}
- 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);
+ if (cookieManager.isOptOut()) {
+ log.debug("{} Guard cookie indicates opt-out", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.RESELECT_FLOW);
return;
}
- cookie = URISupport.doURLDecode(cookie);
- if (cookie == null) {
- log.debug("{} Unable to decode guard cookie", getLogPrefix());
+ final String username = cookieManager.readCookie();
+ if (username == null) {
+ log.debug("{} Guard cookie missing or unreadable", 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);
- }
+ passwordlessContext.setUsername(username);
+ log.debug("{} Extracted username for passwordless authentication from cookie: {}", getLogPrefix(), username);
}
}
\ 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
index 7082ffa8..3bdeb37b 100644
--- 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
@@ -28,17 +28,15 @@ 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.PasswordlessCookieManager;
import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.ComponentInitializationException;
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
@@ -91,14 +89,8 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
/** 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;
+ /** Cookie manager to use. */
+ @NonnullAfterInit private PasswordlessCookieManager cookieManager;
/** Duo authentiction context. */
@NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
@@ -142,38 +134,26 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
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.
+ * Sets {@link PasswordlessCookieManager} to use.
*
* @param manager cookie manager
*/
- public void setCookieManager(@Nullable final CookieManager manager) {
+ public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
checkSetterPreconditions();
- cookieManager = manager;
+ cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
}
-
- /**
- * Sets {@link DataSealer} to use.
- *
- * @param sealer data sealer
- */
- public void setDataSealer(@Nullable final DataSealer sealer) {
- checkSetterPreconditions();
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
- dataSealer = sealer;
+ if (cookieManager == null) {
+ throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
+ }
}
/** {@inheritDoc} */
@@ -189,14 +169,6 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
@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) {
@@ -221,28 +193,31 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
if (!authenticationContext.isResultCacheable() && requireResultCacheable) {
log.debug("{} Non-cacheable authentication, clearing guard cookie if set", getLogPrefix());
- localManager.unsetCookie(localCookieName);
- } else if (!integration.isPasswordless()) {
+ cookieManager.clearCookie();
+ } else if (integration.isPasswordless()) {
+ log.debug("{} Refreshing passwordless cookie for '{}' if set", getLogPrefix(), username);
+ if (!cookieManager.refreshCookie(0)) {
+ log.warn("{} Unable to refresh passwordless cookie for '{}'", getLogPrefix(), username);
+ }
+ } else if (cookieManager.isOptOut()) {
+ log.debug("{} Opt-out cookie found, skipping prompt for '{}'", getLogPrefix(), username);
+ return;
+ } else {
// Read in existing cookie, if any.
- String cookie = localManager.getCookieValue(localCookieName, null);
+ final String cookie = cookieManager.readCookie();
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);
+ if (username.equals(cookie)) {
+ log.debug("{} Refreshing passwordless cookie for '{}' if set", getLogPrefix(), username);
+ if (!cookieManager.refreshCookie(0)) {
+ log.warn("{} Unable to refresh passwordless cookie for '{}'", getLogPrefix(), username);
}
+ return;
+ } else {
+ // Clear the existing cookie to start fresh.
+ log.info("{} Clearing existing guard cookie for original username '{}'", getLogPrefix(),
+ cookie);
+ cookieManager.clearCookie();
}
- localManager.unsetCookie(localCookieName);
}
// This is a new user without an existing cookie set, so establish eligibility.
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 31c619a4..74aea3b0 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
@@ -155,16 +155,24 @@
p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
<!-- Passwordless beans -->
- <bean id="PopulatePasswordlessContext" scope="prototype"
- class="net.shibboleth.idp.plugin.authn.duo.impl.PopulatePasswordlessContext"
+ <bean id="PasswordlessCookieManager" lazy-init="true"
+ class="net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager"
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}" />
+
+ <bean id="PopulatePasswordlessContext" scope="prototype"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.PopulatePasswordlessContext"
+ p:cookieManager-ref="PasswordlessCookieManager" />
<bean id="ClearPasswordlessCookie" scope="prototype"
class="net.shibboleth.idp.plugin.authn.duo.impl.ClearPasswordlessCookie"
- p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
- p:cookieName="%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}" />
+ p:cookieManager-ref="PasswordlessCookieManager" />
+
+ <bean id="CreatePasswordlessCookie"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.CreatePasswordlessCookie" scope="prototype"
+ p:cookieManager-ref="PasswordlessCookieManager"
+ p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
<!-- Duo OIDC beans -->
<bean id="PopulateDuoAuthenticationContext" scope="prototype"
@@ -306,18 +314,10 @@
<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:cookieManager-ref="PasswordlessCookieManager"
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 1ed4db5c..449b404a 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,10 +12,16 @@
<decision-state id="IsPasswordlessPossible">
<if test="!opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() and opensamlProfileRequestContext.isBrowserProfile()"
- then="PopulatePasswordlessContext"
+ then="CheckForViewBypass"
else="RequestUnsupported" />
</decision-state>
+ <decision-state id="CheckForViewBypass">
+ <if test="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).ensureSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext)).isBypassView()"
+ then="CheckDuoOIDCAuthAPI"
+ else="PopulatePasswordlessContext" />
+ </decision-state>
+
<action-state id="PopulatePasswordlessContext">
<evaluate expression="PopulatePasswordlessContext" />
<evaluate expression="'proceed'" />
@@ -40,7 +46,7 @@
<transition on="proceed" to="CheckDuoOIDCAuthAPI" />
<transition on="clear" to="ClearPasswordlessCookie" />
- <transition on="cancel" to="RequestUnsupported" />
+ <transition on="cancel" to="ReselectFlow" />
<on-exit>
<evaluate expression="opensamlProfileRequestContext.addSubcontext(new net.shibboleth.idp.consent.context.ConsentManagementContext(), true).setRevokeConsent(requestParameters._shib_idp_revokeConsent == 'true')" />
@@ -51,7 +57,7 @@
<evaluate expression="ClearPasswordlessCookie" />
<evaluate expression="'proceed'" />
- <transition on="proceed" to="RequestUnsupported" />
+ <transition on="proceed" to="IdentitySwitch" />
</action-state>
<action-state id="ExtractDuoAuthenticationFromHeaders">
@@ -134,7 +140,6 @@
</on-render>
<transition on="proceed" to="CreatePasswordlessCookie" />
- <transition on="cancel" to="proceed" />
</view-state>
<action-state id="CreatePasswordlessCookie">
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
index 7501a29f..770e8487 100644
--- 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
@@ -34,16 +34,16 @@
<form action="$flowExecutionUrl" method="post">
#parse("csrf/csrf.vm")
+ <input type="hidden" name="_eventId_proceed" value="1" />
+
<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>
+ <button type="submit" name="optin" value="1">#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>
+ <button type="submit" name="optin" value="0">#springMessageText("idp.duo.passwordless.optin.no", "No")</button>
</div>
</div>
</form>
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list