[java-idp-plugin-duo] branch dev/JDUO-82 updated: Flesh out default enrollment condition and move it to API package.
Scott Cantor
cantor.2 at osu.edu
Fri Jan 5 20:41:40 UTC 2024
This is an automated email from the git hooks/post-receive script.
scantor pushed a commit to branch dev/JDUO-82
in repository java-idp-plugin-duo.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-duo.git;a=commit;h=7ce0ad2e76ce31fa90ec2afe69965662ac71b64a
The following commit(s) were added to refs/heads/dev/JDUO-82 by this push:
new 7ce0ad2e Flesh out default enrollment condition and move it to API package.
7ce0ad2e is described below
commit 7ce0ad2e76ce31fa90ec2afe69965662ac71b64a
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Fri Jan 5 15:41:37 2024 -0500
Flesh out default enrollment condition and move it to API package.
---
.../authn/duo/PasswordlessEnrolmentCondition.java | 130 +++++++++++++++++++++
.../idp/plugin/authn/duo/model/User.java | 24 ++--
.../plugin/authn/duo/model/WebAuthnCredential.java | 2 +-
.../impl/PasswordlessEnrolmentCheckCondition.java | 74 ------------
.../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml | 40 ++++---
5 files changed, 164 insertions(+), 106 deletions(-)
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrolmentCondition.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrolmentCondition.java
new file mode 100644
index 00000000..6071abf3
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrolmentCondition.java
@@ -0,0 +1,130 @@
+/*
+ * 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 javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+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.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * 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>
+ */
+
+public class PasswordlessEnrolmentCondition extends AbstractInitializableComponent
+ implements BiPredicate<ProfileRequestContext,String> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(PasswordlessEnrolmentCondition.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;
+
+ /** Constructor. */
+ public PasswordlessEnrolmentCondition() {
+ 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));
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (adminClient == null) {
+ throw new ComponentInitializationException("DuoAdminClient cannot be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ public boolean test(@Nullable final ProfileRequestContext profileRequestContext, @Nullable final String username) {
+ checkComponentActive();
+
+ if (profileRequestContext == null || username == null) {
+ log.trace("Unable to checl passwordless enrollment status for '{}'", username);
+ return false;
+ }
+
+ log.trace("Checking passwordless enrollment status for '{}'", username);
+ try {
+ final User response = adminClient.getUser(profileRequestContext, username);
+ final Boolean enrolled = response.isEnrolled();
+
+ if (enrolled == null || !enrolled) {
+ log.trace("User '{}' not enrolled", response.getUserId());
+ return false;
+ }
+
+ final List<WebAuthnCredential> creds = response.getWebAuthnCredentials();
+ if (creds.isEmpty()) {
+ log.trace("User '{}' has no WebAuthn credentials", response.getUserId());
+ return false;
+ }
+
+ log.trace("User '{}' is enrolled with {} WebAuthn credential(s)", response.getUserId(),
+ response.getWebAuthnCredentials().size());
+
+ final boolean allowed = creds.stream().map(WebAuthnCredential::getLabel).anyMatch(allowedLabels::contains);
+ log.debug("User '{}' {} a qualifying WebAuthn credential", response.getUserId(),
+ allowed ? "has" : "does not have");
+ return allowed;
+
+ } catch (final DuoException e) {
+ log.warn("Duo AdminAPI request failed, denying passwordless for '{}'", username, e);
+ return false;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
index b43acc0f..e95122b0 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
@@ -42,7 +42,7 @@ import net.shibboleth.shared.collection.CollectionSupport;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonDeserialize(builder = User.Builder.class)
-public class User {
+public final class User {
/** Generated serialUID. */
public static final long serialVersionUID = 8122423604710803224L;
@@ -68,8 +68,8 @@ public class User {
/**
* An integer indicating the last update to the user via directory sync as a Unix timestamp, or null if the user
- * has never synced with an external directory or if the directory that originally created the user has been deleted
- * from Duo.
+ * has never synced with an external directory or if the directory that originally created the user has been
+ * deleted from Duo.
*/
@JsonProperty("last_directory_sync")
@Nullable private final Object lastDirectorySync;
@@ -147,9 +147,9 @@ public class User {
}
/**
- * Get The user's creation date as a UNIX timestamp.
+ * Get the user's creation date as a UNIX timestamp.
*
- * @return
+ * @return creation date
*/
@JsonProperty("created")
@Nullable public Integer getCreated() {
@@ -159,7 +159,7 @@ public class User {
/**
* Get the user's email address.
*
- * @return
+ * @return email address
*/
@JsonProperty("email")
@Nullable public String getEmail() {
@@ -169,7 +169,7 @@ public class User {
/**
* Get The user's given name.
*
- * @return
+ * @return given name
*/
@JsonProperty("firstname")
@Nullable public Object getFirstname() {
@@ -183,7 +183,7 @@ public class User {
* @return if the user has enrolled
*/
@JsonProperty("is_enrolled")
- public Boolean isEnrolled() {
+ @Nullable public Boolean isEnrolled() {
return enrolled;
}
@@ -192,7 +192,7 @@ public class User {
* the user has never synced with an external directory or if the directory that originally created the
* user has been deleted from Duo.
*
- * @return
+ * @return last directory sync.
*/
@JsonProperty("last_directory_sync")
@Nullable public Object getLastDirectorySync() {
@@ -203,7 +203,7 @@ public class User {
* Get an integer indicating the last time this user logged in, as a Unix timestamp, or null if the user
* has not logged in.
*
- * @return
+ * @return last login time
*/
@JsonProperty("last_login")
@Nullable public Integer getLastLogin() {
@@ -319,10 +319,11 @@ public class User {
return new Builder();
}
+ /** The builder used to create a threadsafe instance of this class.*/
@JsonPOJOBuilder(buildMethodName = "build", withPrefix = "with")
@JsonIgnoreProperties(ignoreUnknown = true)
- /** The builder used to create a threadsafe instance of this class.*/
public static final class Builder {
+
@Nullable private Integer created;
@Nullable private String email;
@Nullable private String firstname;
@@ -454,6 +455,5 @@ public class User {
return new User(this);
}
}
-
}
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
index c36a71fd..64f08a83 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
@@ -26,7 +26,7 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonDeserialize(builder = WebAuthnCredential.Builder.class)
-public class WebAuthnCredential {
+public final class WebAuthnCredential {
/** Free-form label for the WebAuthn credential.*/
@JsonProperty("credential_name")
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PasswordlessEnrolmentCheckCondition.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PasswordlessEnrolmentCheckCondition.java
deleted file mode 100644
index a78a090c..00000000
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PasswordlessEnrolmentCheckCondition.java
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package net.shibboleth.idp.plugin.authn.duo.impl;
-
-import java.util.function.BiPredicate;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-
-import org.opensaml.profile.context.ProfileRequestContext;
-import org.slf4j.Logger;
-
-import net.shibboleth.idp.plugin.authn.duo.DuoException;
-import net.shibboleth.idp.plugin.authn.duo.model.User;
-import net.shibboleth.shared.annotation.ParameterName;
-import net.shibboleth.shared.logic.Constraint;
-import net.shibboleth.shared.primitive.LoggerFactory;
-
-/**
- * A predicate which checks the enrolment status of a user against the Duo Admin APIs. Accepts a profile request
- * context tree and a username string.
- */
-
-//TODO FIXME This is only an example
-
-public class PasswordlessEnrolmentCheckCondition implements BiPredicate<ProfileRequestContext,String> {
-
- /** Class logger. */
- @Nonnull private final Logger log = LoggerFactory.getLogger(PasswordlessEnrolmentCheckCondition.class);
-
- /** The admin client used to access the DuoAdmin API.*/
- @Nonnull private final DefaultDuoAdminClient adminClient;
-
- public PasswordlessEnrolmentCheckCondition(@ParameterName(name="client") @Nonnull final DefaultDuoAdminClient client) {
- adminClient = Constraint.isNotNull(client, "Duo Admin Client can not be null");
- }
-
- /** {@inheritDoc} */
- @Override
- public boolean test(@Nullable final ProfileRequestContext prc, @Nullable final String username) {
-
- if (prc == null || username == null) {
- log.trace("Unable to checl passwordless enrollment status for '{}'", username);
- return false;
- }
-
- log.trace("Checking passwordless enrollment status for '{}'", username);
- try {
- final User response = adminClient.getUser(prc, username);
- log.trace("User '{}' isEnrolled '{}' and has '{}' credentials", response.getUserId(), response.isEnrolled(),
- response.getWebAuthnCredentials().size());
- if (response.isEnrolled() == null) {
- return false;
- }
- return response.isEnrolled();
- } catch (final DuoException ex) {
- log.warn("Duo AdminAPI request failed: '{}'", ex.getMessage(), ex);
- return false;
- }
- }
-
-}
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 fa3bb838..c93bda6f 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
@@ -62,16 +62,16 @@
<!-- Default passwordless integration. -->
<bean id="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegration" lazy-init="false"
- class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
- p:passwordless="true"
- p:APIHost="%{idp.duo.oidc.passwordless.apiHost:%{idp.duo.oidc.apiHost:none}}"
- p:clientId="%{idp.duo.oidc.passwordless.clientId:none}"
- p:secretKey="%{idp.duo.oidc.passwordless.secretKey:none}"
- p:registeredRedirectURI="%{idp.duo.oidc.passwordless.redirectURL:%{idp.duo.oidc.redirectURL:}}"
- p:healthCheckEndpoint="%{idp.duo.oidc.passwordless.endpoint.health:%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}}"
- p:tokenEndpoint="%{idp.duo.oidc.passwordless.endpoint.token:%{idp.duo.oidc.endpoint.token:/oauth/v1/token}}"
- p:authorizeEndpoint="%{idp.duo.oidc.passwordless.endpoint.authorize:%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}}"
- p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}">
+ class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
+ p:passwordless="true"
+ p:APIHost="%{idp.duo.oidc.passwordless.apiHost:%{idp.duo.oidc.apiHost:none}}"
+ p:clientId="%{idp.duo.oidc.passwordless.clientId:none}"
+ p:secretKey="%{idp.duo.oidc.passwordless.secretKey:none}"
+ p:registeredRedirectURI="%{idp.duo.oidc.passwordless.redirectURL:%{idp.duo.oidc.redirectURL:}}"
+ p:healthCheckEndpoint="%{idp.duo.oidc.passwordless.endpoint.health:%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}}"
+ p:tokenEndpoint="%{idp.duo.oidc.passwordless.endpoint.token:%{idp.duo.oidc.endpoint.token:/oauth/v1/token}}"
+ p:authorizeEndpoint="%{idp.duo.oidc.passwordless.endpoint.authorize:%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}}"
+ p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}">
<property name="allowedFactors">
<bean parent="shibboleth.CommaDelimStringArray"
c:_0="#{'%{idp.duo.oidc.passwordless.allowedFactors:Platform authenticator (2fa),Roaming authenticator (2fa)}'.trim()}" />
@@ -83,7 +83,7 @@
<!-- Default Duo Admin API Integration -->
<bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" lazy-init="false"
class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
- p:APIHost="%{idp.duo.oidc.admin.apiHost:none}"
+ 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.DuoAdminIntegrationStrategy" parent="shibboleth.Functions.Constant"
@@ -155,8 +155,6 @@
p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
<!-- Passwordless beans -->
- <!-- TODO: change to shibboleth.BiConditions.TRUE once API moves to 5.1. -->
- <bean id="DefaultPasswordlessCondition" parent="shibboleth.BiConditions.Expression" c:_0="true" />
<bean id="CheckPasswordlessEnrollment"
class="net.shibboleth.idp.plugin.authn.duo.impl.CheckPasswordlessEnrollment" scope="prototype"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
@@ -171,14 +169,18 @@
p:trim="%{idp.authn.oidc.trim:true}"
p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}" />
- <!-- Example passwordless condition that uses the admin API -->
- <bean id="shibboleth.authn.DuoOIDC.Passwordless.Condition"
- class="net.shibboleth.idp.plugin.authn.duo.impl.PasswordlessEnrolmentCheckCondition"
- c:_0="#{getObject('shibboleth.authn.DuoOIDC.Admin.Api.Client') ?: getObject('DefaultDuoAdminClient')}"/>
-
+ <!-- Default passwordless condition that uses the admin API -->
+ <bean id="DefaultPasswordlessCondition"
+ class="net.shibboleth.idp.plugin.authn.duo.PasswordlessEnrolmentCondition"
+ p:duoAdminClient="#{getObject('shibboleth.authn.DuoOIDC.AdminClient') ?: getObject('DefaultDuoAdminClient')}">
+ <property name="allowedLabels">
+ <bean parent="shibboleth.CommaDelimStringArray"
+ c:_0="#{'%{idp.duo.oidc.passwordless.allowedLabels:}'.trim()}" />
+ </property>
+ </bean>
<!-- Singleton, shared, Duo Admin API Beans -->
- <bean id="DefaultDuoAdminClient" class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoAdminClient" scope="singleton"
+ <bean id="DefaultDuoAdminClient" class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoAdminClient"
p:objectMapper-ref="shibboleth.JSONObjectMapper"
p:httpClient="#{getObject('shibboleth.authn.DuoOIDC.Admin.HttpClient') ?: getObject('shibboleth.InternalHttpClient')}"
p:httpClientSecurityParameters="#{getObject('shibboleth.authn.DuoOIDC.NonBrowser.HttpClientSecurityParameters')}"
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list