[java-idp-plugin-duo] 01/01: JDUO-80 - Work in progress
Scott Cantor
cantor.2 at osu.edu
Wed Dec 20 20:57:12 UTC 2023
This is an automated email from the git hooks/post-receive script.
scantor pushed a commit to branch dev/JDUO-80
in repository java-idp-plugin-duo.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-duo.git;a=commit;h=6ee51462daabc1c1c5b602907e987cb213b14644
commit 6ee51462daabc1c1c5b602907e987cb213b14644
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Wed Dec 20 15:57:09 2023 -0500
JDUO-80 - Work in progress
---
.../authn/duo/context/DuoPasswordlessContext.java | 85 ++++++++
.../duo/impl/ExtractPasswordlessUsername.java | 230 +++++++++++++++++++++
.../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml | 16 +-
.../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml | 41 +++-
.../idp/plugin/authn/duo/nimbus/views/username.vm | 93 +++++++++
5 files changed, 460 insertions(+), 5 deletions(-)
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
new file mode 100644
index 00000000..9eab6c28
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.authn.duo.context;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+
+/**
+ * Context that tracks username and signals usage of Duo in a passwordless mode.
+ *
+ * <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>
+ *
+ * @parent {@link AuthenticationContext}
+ * @added By configuration to signal username collection and enrollment checking
+ *
+ * @since 2.1.0
+ */
+public final class DuoPasswordlessContext extends BaseContext {
+
+ /** Username. */
+ @Nullable private String username;
+
+ /** Whether user has appropriate devices enrolled. */
+ boolean enrolled;
+
+ /**
+ * Get the username.
+ *
+ * @return username
+ */
+ @Nullable public String getUsername() {
+ return username;
+ }
+
+ /**
+ * Set the username.
+ *
+ * @param name username
+ *
+ * @return this context
+ */
+ @Nonnull public DuoPasswordlessContext setUsername(@Nullable final String name) {
+ username = name;
+ 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;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
new file mode 100644
index 00000000..1fae5dfc
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.duo.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.google.common.net.UrlEscapers;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.authn.AbstractExtractionAction;
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext;
+import net.shibboleth.idp.session.IdPSession;
+import net.shibboleth.idp.session.context.SessionContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * An action to populate a username into a {@link DuoPasswordlessContext}, either from a form
+ * submission, a cookie, or an existing session.
+ *
+ * <p>If no username is found, then {@link AuthnEventIds#UNKNOWN_USERNAME} is signaled.</p>
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
+ * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found.
+ *
+ * @since 2.1.0
+ */
+public class ExtractPasswordlessUsername extends AbstractExtractionAction {
+
+ /** Cookie name to cache username. */
+ @Nonnull public static final String COOKIE_NAME = "_shibidp_duo_username";
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(ExtractPasswordlessUsername.class);
+
+ /** Strategy used to locate the {@link DuoPasswordlessContext} to populate. */
+ @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
+
+ /** Form parameter name to carry username. */
+ @Nonnull private String usernameFieldName = "j_username";
+
+ /** Optional cookie manager to use. */
+ @Nullable private CookieManager cookieManager;
+
+ /** Optional data sealer to use. */
+ @Nullable private DataSealer dataSealer;
+
+ /** Constructor.*/
+ public ExtractPasswordlessUsername() {
+ duoPasswordlessContextLookupStrategy =
+ new ChildContextLookup<>(DuoPasswordlessContext.class, true).compose(
+ new ChildContextLookup<>(AuthenticationContext.class));
+ }
+
+ /**
+ * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setDuoContextCreationStrategy(
+ @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
+ checkSetterPreconditions();
+
+ duoPasswordlessContextLookupStrategy =
+ Constraint.isNotNull(strategy, "DuoPasswordlessContext lookup strategy cannot be null");
+ }
+
+ /**
+ * Sets the name of the form field to carry the username.
+ *
+ * @param name field name
+ */
+ public void setUsernameFieldName(@Nonnull final String name) {
+ usernameFieldName = Constraint.isNotNull(StringSupport.trimOrNull(name) ,
+ "Username form field name cannot be null or empty");
+ }
+
+ /**
+ * Sets optional {@link CookieManager} to use.
+ *
+ * @param manager cookie manager
+ */
+ public void setCookieManager(@Nullable final CookieManager manager) {
+ checkSetterPreconditions();
+
+ cookieManager = manager;
+ }
+
+ /**
+ * Sets optional {@link DataSealer} to use.
+ *
+ * @param sealer data sealer
+ */
+ public void setDataSealer(@Nullable final DataSealer sealer) {
+ checkSetterPreconditions();
+
+ dataSealer = sealer;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final AuthenticationContext authenticationContext) {
+
+ final DuoPasswordlessContext context = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
+ if (context == null) {
+ log.error("{} Error locating DuoPasswordlessContext", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return;
+ }
+
+ String username = getUsernameFromForm();
+ if (username != null) {
+ log.debug("{} Populating username '{}' from form submission into Duo passwordless context", getLogPrefix(),
+ username);
+ context.setUsername(username);
+ return;
+ }
+
+ username = getUsernameFromCookie(profileRequestContext);
+ if (username != null) {
+ log.debug("{} Populating cached username '{}' from cookie into Duo passwordless context", getLogPrefix(),
+ username);
+ context.setUsername(username);
+ return;
+ }
+
+ username = getUsernameFromSession(profileRequestContext);
+ if (username != null) {
+ log.debug("{} Populating username '{}' from session into Duo passwordless context", getLogPrefix(),
+ username);
+ context.setUsername(username);
+ return;
+ }
+
+ if (context.getUsername() == null) {
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
+ }
+ }
+
+ /**
+ * Gets the username from a form submission.
+ *
+ * @return submitted username, after applying any configured transforms
+ */
+ @Nullable private String getUsernameFromForm() {
+
+ final HttpServletRequest request = getHttpServletRequest();
+ if (request != null) {
+ return applyTransforms(request.getParameter(usernameFieldName));
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the username from an existing sealed cookie, if any.
+ *
+ * @param profileRequestContext profile request context
+ *
+ * @return username from existing sealed cookie, or null
+ */
+ @Nullable private String getUsernameFromCookie(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (cookieManager != null && dataSealer != null) {
+ final String cookie = cookieManager.getCookieValue(COOKIE_NAME, null);
+ if (cookie != null) {
+ try {
+ assert dataSealer != null;
+ return dataSealer.unwrap(UrlEscapers.urlFormParameterEscaper().escape(cookie));
+ } catch (final DataSealerException e) {
+ log.warn("{} Unable to unwrap sealed username cookie", getLogPrefix(), e);
+ assert cookieManager != null;
+ cookieManager.unsetCookie(COOKIE_NAME);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the username from an existing {@link IdPSession}, if any.
+ *
+ * @param profileRequestContext profile request context
+ *
+ * @return username from existing session, or null
+ */
+ @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext) {
+ final SessionContext sessionContext = profileRequestContext.getSubcontext(SessionContext.class);
+ if (sessionContext != null) {
+ final IdPSession idpSession = sessionContext.getIdPSession();
+ if (idpSession != null) {
+ return idpSession.getPrincipalName();
+ }
+ }
+
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/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 e19fca86..62402c59 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
@@ -64,9 +64,7 @@
to change the location of the user config file. -->
<import resource="conditional:%{idp.home}/conf/authn/%{idp.duo.oidc.user.config:duo-oidc-authn-config.xml}" />
- <!--
- Non-Browser actions and beans. Code lives in idp-authn-impl for now, may migrate here later.
- -->
+ <!-- Non-Browser actions and beans -->
<bean id="ExtractDuoAuthenticationFromHeaders" scope="prototype"
class="net.shibboleth.idp.plugin.authn.duo.impl.ExtractDuoAuthenticationFromHeaders"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
@@ -99,7 +97,17 @@
p:classifiedMessages="#{getObject('shibboleth.authn.DuoOIDC.ClassifiedMessageMap')}"
p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
- <!-- Duo OIDC AuthAPI beans -->
+ <!-- Passwordless beans -->
+ <bean id="ExtractPasswordlessUsername"
+ class="net.shibboleth.idp.plugin.authn.duo.impl.ExtractPasswordlessUsername" scope="prototype"
+ p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+ p:usernameFieldName="#{%{idp.authn.DuoOIDC.usernameFieldName:j_username}'.trim()}"
+ p:lowercase="%{idp.authn.DuoOIDC.lowercase:false}"
+ p:uppercase="%{idp.authn.DuoOIDC.uppercase:false}"
+ p:trim="%{idp.authn.Password.trim:true}"
+ p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}" />
+
+ <!-- Duo OIDC beans -->
<bean id="PopulateDuoAuthenticationContext" scope="prototype"
class="net.shibboleth.idp.plugin.authn.duo.impl.PopulateDuoAuthenticationContext"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
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 6956f4d1..d00ced49 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
@@ -13,7 +13,7 @@
<!-- Fall through to a different flow if header extract fails on a passive or non-browser request. -->
<transition on="#{ opensamlProfileRequestContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() || !opensamlProfileRequestContext.isBrowserProfile() }" to="ReselectFlow" />
- <transition on="NoCredentials" to="CheckDuoOIDCAuthAPI" />
+ <transition on="NoCredentials" to="CheckForPasswordless" />
</action-state>
<action-state id="ValidateDuoAuthAPI">
@@ -23,6 +23,45 @@
<transition on="proceed" to="proceed" />
</action-state>
+ <decision-state id="CheckForPasswordless">
+ <if test="opensamlProfileRequestContext.containsSubcontext(T(net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext))"
+ then="ExtractPasswordlessUsername"
+ else="CheckDuoOIDCAuthAPI" />
+ </decision-state>
+
+ <action-state id="ExtractPasswordlessUsername">
+ <evaluate expression="ExtractPasswordlessUsername" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="CheckEnrollmentStatus" />
+ <transition on="UnknownUsername" to="UsernameCollectionView" />
+ </action-state>
+
+ <action-state id="CheckEnrollmentStatus">
+ <evaluate expression="CheckEnrollmentStatus" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="UsernameCollectionView" />
+ </action-state>
+
+ <view-state id="UsernameCollectionView" view="username">
+ <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('shibboleth.CSPDigester')" result="viewScope.cspDigester" />
+ <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CSPNonce')" 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="CheckDuoOIDCAuthAPI" />
+ <transition on="CheckEnrollmentStatus" to="CheckEnrollmentStatus" />
+ </view-state>
+
<action-state id="CheckDuoOIDCAuthAPI">
<evaluate expression="PopulateDuoAuthenticationContext" />
<evaluate expression="HealthCheckDuoOIDCAuthAPI" />
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
new file mode 100644
index 00000000..6a7c9845
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
@@ -0,0 +1,93 @@
+##
+## 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
+## rpUIContext - the context with SP UI information from the metadata
+## 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
+##
+#set ($rpContext = $profileRequestContext.getSubcontext('net.shibboleth.profile.context.RelyingPartyContext'))
+#set ($username = $authenticationContext.getSubcontext('net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext').getUsername())
+##
+<!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")" />
+
+ #set ($serviceName = $rpUIContext.serviceName)
+ #if ($serviceName && !$rpContext.getRelyingPartyId().contains($serviceName))
+ <h1>#springMessageText("idp.login.loginTo", "Login to") $encoder.encodeForHTML($serviceName)</h1>
+ #end
+ </header>
+
+ <section>
+ <form action="$flowExecutionUrl" method="post">
+ #parse("csrf/csrf.vm")
+
+ #*
+ //
+ // SP Description & Logo (optional)
+ // These idpui lines will display added information (if available
+ // in the metadata) about the Service Provider (SP) that requested
+ // authentication. These idpui lines are "active" in this example
+ // (not commented out) - this extra SP info will be displayed.
+ // Remove or comment out these lines to stop the display of the
+ // added SP information.
+ //
+ *#
+ #set ($logo = $rpUIContext.getLogo())
+ #if ($logo)
+ <img class="service-logo" src= "$encoder.encodeForHTMLAttribute($logo)" alt="$encoder.encodeForHTMLAttribute($serviceName)">
+ #end
+ #set ($desc = $rpUIContext.getServiceDescription())
+ #if ($desc)
+ <p>$encoder.encodeForHTML($desc)</p>
+ #end
+
+ <label for="username">#springMessageText("idp.login.username", "Username")</label>
+ <input id="username" name="j_username" type="text"
+ value="#if($username)$encoder.encodeForHTML($username)#end" />
+
+ <input type="checkbox" name="donotcache" value="1" id="donotcache" />
+ <label for="donotcache">#springMessageText("idp.duo.donotcache", "Don't Remember Me")</label>
+
+ <div class="grid">
+ <div class="grid-item">
+ <button type="submit" name="_eventId_proceed"
+ >#springMessageText("idp.duo.continue", "Continue")</button>
+ </div>
+ </div>
+ </form>
+
+ <ul>
+ <li><a href="#springMessageText("idp.url.password.reset", '#')">#springMessageText("idp.login.forgotPassword", "Forgot your password?")</a></li>
+ <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
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list