[java-idp-plugin-webauthn] 06/11: JWEBAUTHN-27 - Add basic authenticator policy
Phil Smart
philip.smart at jisc.ac.uk
Fri Oct 18 17:13:31 UTC 2024
This is an automated email from the git hooks/post-receive script.
philsmart pushed a commit to branch main
in repository java-idp-plugin-webauthn.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-webauthn.git;a=commit;h=9f105b36d189a3a941a89fad61e0045e01ceb3e4
commit 9f105b36d189a3a941a89fad61e0045e01ceb3e4
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Tue Oct 8 14:34:31 2024 +0100
JWEBAUTHN-27 - Add basic authenticator policy
- Add credential policy checking to authentication flow
- Add default chain policy rules
- Add default 2faOnly rule
https://shibboleth.atlassian.net/browse/JWEBAUTHN-27
---
.../authn/WebAuthnAuthenticationEventIds.java | 4 +
.../context/WebAuthnAuthenticationContext.java | 24 +++
.../WebAuthnErrorMessageLookupFunction.java | 127 ++++++++++++++++
.../authn/webauthn/policy/CredentialPolicy.java | 63 ++++++++
.../webauthn/storage/CredentialRegistration.java | 10 ++
.../storage/WebAuthnCredentialRepository.java | 12 ++
.../authn/webauthn/impl/CheckCredentialPolicy.java | 167 +++++++++++++++++++++
.../policy/impl/AbstractCredentialPolicyRule.java | 159 ++++++++++++++++++++
.../impl/SecondFactorOnlyCredentialPolicyRule.java | 81 ++++++++++
.../IdPStorageServiceCredentialRespository.java | 19 +++
.../idp/flows/authn/WebAuthn/webauthn-beans.xml | 23 +++
.../idp/flows/authn/WebAuthn/webauthn-flow.xml | 6 +
.../authn/webauthn/conf/authn/webauthn.properties | 7 +
.../idp/plugin/authn/webauthn/messages.properties | 5 +-
.../webauthn/flow/AbstractWebAuthnFlowTest.java | 20 +++
.../flow/TestUsernameslessFlowWithPolicy.java | 109 ++++++++++++++
...hnEnvironmentApplicationContextInitializer.java | 2 +-
...ssWithPolicyApplicationContextInitializer.java} | 19 +--
.../authn/webauthn/impl/AbstractWebAuthnTest.java | 17 ++-
.../SecondFactorOnlyCredentialPolicyRuleTest.java | 67 +++++++++
.../storage/impl/InMemoryRegistrationStorage.java | 12 ++
.../test/resources/logback-webauthn-flow-test.xml | 25 +++
22 files changed, 963 insertions(+), 15 deletions(-)
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java
index f08b062..e026d4d 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java
@@ -32,6 +32,10 @@ public final class WebAuthnAuthenticationEventIds {
/** The user has no registered WebAuthn credentials for the user handle supplied. */
@Nonnull @NotEmpty
public static final String USER_HANDLE_NOT_REGISTERED = "UserHandleNotRegistered";
+
+ /** A credential policy rejected the credential/authenticator.*/
+ @Nonnull @NotEmpty
+ public static final String CREDENTIAL_POLICY_REJECTION = "CredentialPolicyRejection";
/** Private constructor.*/
private WebAuthnAuthenticationEventIds() {
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
index fd715b6..67a6a07 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
@@ -23,6 +23,7 @@ import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
import com.yubico.webauthn.data.PublicKeyCredential;
import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
import net.shibboleth.shared.logic.Constraint;
@@ -49,6 +50,9 @@ public final class WebAuthnAuthenticationContext extends BaseWebAuthnContext {
/** The public key credential request options for generating an authentication assertion.*/
@Nullable private PublicKeyCredentialRequestOptions publicKeyCredentialRequestOptions;
+
+ /** The authentication credential signalled by the authenticator.*/
+ @Nullable private CredentialRegistration authenticationCredential;
/**
* Get the credential Id.
@@ -181,5 +185,25 @@ public final class WebAuthnAuthenticationContext extends BaseWebAuthnContext {
public boolean isSecondFactor() {
return secondFactor;
}
+
+ /**
+ * Set the credential used by the authenticator for this authentication.
+ *
+ * @param credential The authentication credential to set.
+ */
+ @Nonnull public WebAuthnAuthenticationContext setAuthenticationCredential(
+ @Nullable final CredentialRegistration credential) {
+ authenticationCredential = credential;
+ return this;
+ }
+
+ /**
+ * Get the credential used by the authenticator for this authentication.
+ *
+ * @return the authentication credential.
+ */
+ @Nullable public CredentialRegistration getAuthenticationCredential() {
+ return authenticationCredential;
+ }
}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/navigate/WebAuthnErrorMessageLookupFunction.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/navigate/WebAuthnErrorMessageLookupFunction.java
new file mode 100644
index 0000000..26e2b97
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/navigate/WebAuthnErrorMessageLookupFunction.java
@@ -0,0 +1,127 @@
+/*
+ * 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.webauthn.context.navigate;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.messaging.context.navigate.ContextDataLookupFunction;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.context.support.ApplicationObjectSupport;
+import org.springframework.context.support.MessageSourceAccessor;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.AuthenticationErrorContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationErrorContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * A function that examines the state of a request and produces an appropriate message for WebAuthn flow views.
+ *
+ * <p>NOTE: The result of this function is <strong>NOT</strong> HTML-encoded in any way and must
+ * be encoded for safety if used.</p>
+ */
+public class WebAuthnErrorMessageLookupFunction extends ApplicationObjectSupport
+ implements ContextDataLookupFunction<ProfileRequestContext,String>{
+
+ /** Message ID to use for generic messages. */
+ private String genericMessageID;
+
+ /** Lookup strategy to locate the WebAuthn registration information context. */
+ @Nonnull
+ private Function<ProfileRequestContext,WebAuthnRegistrationErrorContext> webauthnErrorContextLookupStrategy;
+
+ /** Constructor.*/
+ public WebAuthnErrorMessageLookupFunction() {
+ webauthnErrorContextLookupStrategy = new ChildContextLookup<>(WebAuthnRegistrationErrorContext.class)
+ .compose(new ChildContextLookup<>(WebAuthnRegistrationContext.class));
+ }
+
+ /**
+ * Set the strategy used to lookup the {@link WebAuthnRegistrationErrorContext}.
+ *
+ * @param strategy The strategy to set.
+ */
+ public void setWebauthnErrorContextLookupStrategy(
+ final Function<ProfileRequestContext, WebAuthnRegistrationErrorContext> strategy) {
+ webauthnErrorContextLookupStrategy = Constraint.isNotNull(strategy,
+ "WebAuthnRegistrationErrorContextLookupStrategy can not be null");
+ }
+
+ /**
+ * Sets whether non-message-based error messages should be exposed or turned into a more
+ * generic value.
+ *
+ * @param id message ID
+ */
+ public void setGenericMessageID(@Nullable final String id) {
+ genericMessageID = StringSupport.trimOrNull(id);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String apply(@Nullable final ProfileRequestContext input) {
+
+ final MessageSourceAccessor messageSource = getMessageSourceAccessor();
+ if (messageSource == null) {
+ return null;
+ }
+
+ final AuthenticationContext authCtx = input != null ? input.getSubcontext(AuthenticationContext.class) : null;
+ final AuthenticationErrorContext errorCtx =
+ authCtx != null ? authCtx.getSubcontext(AuthenticationErrorContext.class) : null;
+
+ if (errorCtx == null) {
+ return null;
+ }
+
+ final String classifiedError = errorCtx.getLastClassifiedError();
+ if (classifiedError != null && !classifiedError.isEmpty()) {
+ return getClassifiedMessage(messageSource, classifiedError);
+ }
+ return null;
+ }
+
+ /**
+ * Get classified message.
+ *
+ * @param messageSource Spring message source
+ * @param classifiedMessage classified message
+ *
+ * @return mapped message, or null if an empty string was produced.
+ */
+ @Nullable private String getClassifiedMessage(@Nonnull final MessageSourceAccessor messageSource,
+ @Nonnull final String classifiedMessage) {
+
+ String message = messageSource.getMessage(classifiedMessage, "");
+ if (message.isEmpty()) {
+ message = messageSource.getMessage( genericMessageID != null ? genericMessageID :
+ "idp.webauthn.message", "Registration result: "
+ + classifiedMessage);
+ }
+ if (message.isEmpty()) {
+ return null;
+ }
+ return message;
+
+ }
+
+
+}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/CredentialPolicy.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/CredentialPolicy.java
new file mode 100644
index 0000000..478af5c
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/CredentialPolicy.java
@@ -0,0 +1,63 @@
+/*
+ * 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.webauthn.policy;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.component.IdentifiedComponent;
+
+/**
+ * An API for applying policy checks to an authentication credential.
+ */
+public interface CredentialPolicy extends IdentifiedComponent {
+
+ /**
+ * Representation of the three outcomes of an CredentialPolicy.
+ */
+ public enum CredentialPolicyOutcome {
+ /** Allow the credential to be used. */
+ ACCEPT,
+ /** Reject the credential. */
+ REJECT,
+ /** The policy was not active and should be ignored. */
+ IGNORE;
+
+ /**
+ * Helper method to create an {@link CredentialPolicyOutcome} from a boolean flag.
+ *
+ * @param outcome the boolean outcome
+ * @return the outcome appropriate for the given boolean
+ */
+ @Nonnull public static CredentialPolicyOutcome of(final boolean outcome) {
+ return outcome ? CredentialPolicyOutcome.ACCEPT : CredentialPolicyOutcome.REJECT;
+ }
+ }
+
+ /**
+ * Execute the policy.
+ *
+ * @param credential the credential to accept or reject.
+ * @param prc the profile request context
+ *
+ * @return {@link CredentialPolicyOutcome#ACCEPT} if allowed, {@link CredentialPolicyOutcome#REJECT} if rejected,
+ * and {@link CredentialPolicyOutcome#IGNORE} otherwise.
+ */
+ CredentialPolicyOutcome evaluate(@Nonnull final CredentialRegistration credential,
+ @Nonnull final ProfileRequestContext prc);
+
+}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java
index 912c45c..05fba1f 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java
@@ -224,6 +224,16 @@ public final class CredentialRegistration {
return credential.getCredentialId().getBase64Url();
}
+ /**
+ * Get the credential ID as a Hex encoded string.
+ *
+ * @return the credential ID Hex encoded
+ */
+ @JsonIgnore
+ public String getCredentialIdHex() {
+ return credential.getCredentialId().getHex();
+ }
+
/**
* Get the capabilities of the authenticator that created this credential.
*
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java
index 9d36b49..e85faf9 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java
@@ -79,6 +79,18 @@ public interface WebAuthnCredentialRepository extends CredentialRepository {
*/
@Nonnull Optional<CredentialRegistration> getRegistrationByUsernameAndCredentialId(@Nonnull final String username,
@Nonnull final ByteArray credentialId);
+
+
+ /**
+ * Get the credential belonging to the user by userHandle and credentialID.
+ *
+ * @param credentialId the identifier of the credential to find
+ * @param userHandle the user handle (user.id)
+ *
+ * @return the credential registration if found.
+ */
+ Optional<CredentialRegistration> getRegistrationByUserHandleAndCredentialId(@Nonnull final ByteArray credentialId,
+ @Nonnull final ByteArray userHandle);
/**
* Remove the registration for the given user.
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
new file mode 100644
index 0000000..8303b7e
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
@@ -0,0 +1,167 @@
+/*
+ * 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.webauthn.impl;
+
+import java.util.Optional;
+
+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.yubico.webauthn.data.AuthenticatorAssertionResponse;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.AuthenticationErrorContext;
+import net.shibboleth.idp.plugin.authn.webauthn.authn.WebAuthnAuthenticationEventIds;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy.CredentialPolicyOutcome;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.WebAuthnCredentialRepository;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A policy engine action that checks with the configured policy if the credential can be used to authenticate.
+ *
+ * @event {EventIds#INVALID_PROFILE_CTX}\
+ * @event {WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION}
+ * @pre <pre>ProfileRequestContext.getSubcontext(WebAuthnRegistrationContext.class) != null</pre>
+ * @post the credential is allowed to be used for authentication, or an error event is triggered
+ */
+public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthenticationContext> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(CheckCredentialPolicy.class);
+
+ /** The stashed assertion response.*/
+ @NonnullBeforeExec
+ private PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> assertion;
+
+ /** The credential policy to check.*/
+ @Nullable private CredentialPolicy credentialPolicy;
+
+ /** The credential repository to use.*/
+ @NonnullAfterInit private WebAuthnCredentialRepository repository;
+
+ /** The stashed authentication context.*/
+ @NonnullBeforeExec private AuthenticationContext authnContext;
+
+ /**
+ * Constructor.
+ */
+ protected CheckCredentialPolicy() {
+ super(new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
+ compose(new ChildContextLookup<>(AuthenticationContext.class)));
+ }
+
+ /**
+ * Set the policy to verify that the credential can be used for authentication.
+ *
+ * @param policy The authenticator policy to set.
+ */
+ public void setCredentialPolicy(@Nullable final CredentialPolicy policy) {
+ checkSetterPreconditions();
+ credentialPolicy = policy;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ repository = getCredentialRepository();
+ if (repository == null) {
+ throw new ComponentInitializationException("Credential repository can not be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final WebAuthnAuthenticationContext context) {
+
+ if (!super.doPreExecute(profileRequestContext, context)) {
+ return false;
+ }
+
+ assertion = context.getPublicKeyCredentialAssertionResponse();
+ if (assertion == null) {
+ log.error("{} Assertion not available in registration context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ authnContext = profileRequestContext.getSubcontext(AuthenticationContext.class);
+ if (authnContext == null) {
+ log.error("{} Authentication context not available", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final WebAuthnAuthenticationContext context) {
+
+ final CredentialPolicy localPolicy = credentialPolicy;
+ if (localPolicy == null) {
+ // If no policy, nothing can be applied
+ log.trace("{} No authenticator policy to apply", getLogPrefix());
+ return;
+ }
+
+ final Optional<ByteArray> userHandle = assertion.getResponse().getUserHandle();
+ if (userHandle.isEmpty()) {
+ log.trace("{} UserHandle could not be found in the response",getLogPrefix());
+ return;
+ }
+
+ final Optional<CredentialRegistration> credential =
+ repository.getRegistrationByUserHandleAndCredentialId(assertion.getId(), userHandle.get());
+ if (credential.isEmpty()) {
+ log.trace("{} UserHandle '{}' has no registered credential",getLogPrefix(), userHandle.get().getHex());
+ return;
+ }
+
+ final CredentialPolicyOutcome outcome = localPolicy.evaluate(credential.get(), profileRequestContext);
+ if (outcome == CredentialPolicyOutcome.REJECT) {
+ log.warn("{} CredentialPolicy '{}' has rejected credential '{}'", getLogPrefix(), localPolicy.getId(),
+ credential.get().getCredential().getCredentialId().getBase64Url());
+ authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
+ WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+ ActionSupport.buildEvent(profileRequestContext, WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+ return;
+ } else if (outcome == CredentialPolicyOutcome.IGNORE) {
+ log.trace("{} CredentialPolicy '{}' was not active for credential '{}', accepting", getLogPrefix(), localPolicy.getId(),
+ credential.get().getCredential().getCredentialId().getBase64Url());
+ return;
+ }
+ log.debug("{} CredentialPolicy '{}' accepted credential '{}'", getLogPrefix(), localPolicy.getId(),
+ credential.get().getCredential().getCredentialId().getHex());
+
+ }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/AbstractCredentialPolicyRule.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/AbstractCredentialPolicyRule.java
new file mode 100644
index 0000000..f6cd2d2
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/AbstractCredentialPolicyRule.java
@@ -0,0 +1,159 @@
+/*
+ * 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.webauthn.policy.impl;
+
+import java.util.function.BiPredicate;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.yubico.fido.metadata.FidoMetadataService;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A base class for {@link CredentialPolicy credential policies}. Ensures the credential is not null before it is
+ * passed to the policy rule implementation. Can be enabled and disabled using the activiation condition.
+ *
+ * <p>Returns {@link CredentialPolicyOutcome#ACCEPT} if the credential is accepted, returns
+ * {@link CredentialPolicyOutcome#REJECT} if the credential is rejected, returns
+ * {@link CredentialPolicyOutcome#IGNORE} if the rule is to be ignored.</p>
+ */
+ at ThreadSafeAfterInit
+public abstract class AbstractCredentialPolicyRule extends AbstractIdentifiableInitializableComponent
+ implements CredentialPolicy {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(AbstractCredentialPolicyRule.class);
+
+ /** FIDO metadata service resolver.*/
+ @Nullable private FidoMetadataService fidoMetadataService;
+
+ /** Does this policy rule apply? Default is true. */
+ @Nonnull private BiPredicate<CredentialRegistration, ProfileRequestContext> activationCondition;
+
+ /** Lookup strategy to locate the WebAuthn context. */
+ @Nonnull private Function<ProfileRequestContext,WebAuthnAuthenticationContext> webauthnContextLookupStrategy;
+
+ /** Constructor.*/
+ protected AbstractCredentialPolicyRule() {
+ //default is always true
+ activationCondition = (cred,prc) -> true;
+ webauthnContextLookupStrategy = new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
+ compose(new ChildContextLookup<>(AuthenticationContext.class));
+ }
+
+ /**
+ * Set the WebAuthn context lookup strategy to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setWebAuthnContextLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext,WebAuthnAuthenticationContext> strategy) {
+ checkSetterPreconditions();
+
+ webauthnContextLookupStrategy =
+ Constraint.isNotNull(strategy, "WebAuthnContextLookuplookup strategy cannot be null");
+ }
+
+
+ /**
+ * Set an activation condition for this policy rule.
+ *
+ * @param condition condition to set
+ */
+ public void setActivationConditionStrategy(
+ @Nonnull final BiPredicate<CredentialRegistration, ProfileRequestContext> condition) {
+ checkSetterPreconditions();
+ activationCondition = Constraint.isNotNull(condition, "Activation condition cannot be null");
+ }
+
+ /**
+ * Set an activation condition for this policy rule.
+ *
+ * @param flag the flag to set
+ */
+ public void setActivationCondition(final boolean flag) {
+ checkSetterPreconditions();
+ activationCondition = flag ? (cred,prc) -> true : (cred,prc) -> false;
+ }
+
+ /**
+ * Set the FIDO metadata source.
+ *
+ * @param service the attestation trust source.
+ */
+ public void setFidoMetadataService(@Nullable final FidoMetadataService trustSource) {
+ checkSetterPreconditions();
+ fidoMetadataService = trustSource;
+ }
+
+ /**
+ * Get the metadata service to use.
+ *
+ * @return the metadata service.
+ */
+ @Nullable protected FidoMetadataService getFidoMetadataService() {
+ return fidoMetadataService;
+ }
+
+ /** {@inheritDoc}
+ *
+ * <p>
+ * Tests the policy is active, extracts and presents the {@link WebAuthnAuthenticationContext} context from the
+ * {@link ProfileRequestContext} for convenience.
+ * </p>
+ */
+ @Override
+ public CredentialPolicyOutcome evaluate(
+ @Nonnull final CredentialRegistration credential, @Nonnull final ProfileRequestContext prc) {
+ if (!activationCondition.test(credential, prc)) {
+ //not active for this request
+ log.trace("CredentialPolicy rule '{}' not active for this request", getId());
+ return CredentialPolicyOutcome.IGNORE;
+ }
+ final WebAuthnAuthenticationContext webAuthnContext = webauthnContextLookupStrategy.apply(prc);
+ if (credential == null || prc == null || webAuthnContext == null) {
+ return CredentialPolicyOutcome.REJECT;
+ }
+ return doEvaluate(credential, prc, webAuthnContext);
+ }
+
+ /**
+ * Execute the policy. Implementations should override this method.
+ *
+ * @param credential the credential the check the policy rules for
+ * @param prc the profile request context
+ * @param webAuthnContext the WebAuthn authentication context
+ *
+ * @return the credential policy outcome
+ */
+ protected abstract CredentialPolicyOutcome doEvaluate(
+ @Nonnull final CredentialRegistration credential, @Nonnull final ProfileRequestContext prc,
+ @Nonnull final WebAuthnAuthenticationContext webAuthnContext);
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRule.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRule.java
new file mode 100644
index 0000000..99b917f
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRule.java
@@ -0,0 +1,81 @@
+/*
+ * 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.webauthn.policy.impl;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.SecondFactorOnlyAuthenticatorInspector;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A {@link CredentialPolicy} that rejects credentials used in sole-factor mode if created by authenticators which
+ * should only be used for second factor authentication.
+ */
+public class SecondFactorOnlyCredentialPolicyRule extends AbstractCredentialPolicyRule {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(SecondFactorOnlyCredentialPolicyRule.class);
+
+ /** {@inheritDoc} */
+ @Override
+ public CredentialPolicyOutcome doEvaluate(
+ @Nonnull final CredentialRegistration credential, @Nonnull final ProfileRequestContext prc,
+ @Nonnull final WebAuthnAuthenticationContext webAuthnContext) {
+
+ final Map<String,String> capabilities = credential.getAuthenticatorCapabilities();
+ if (capabilities.containsKey(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME)) {
+ final String value = capabilities.get(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME);
+ final boolean isTrue = Boolean.parseBoolean(value);
+
+ if (!webAuthnContext.isSecondFactor() && isTrue) {
+ if (log.isTraceEnabled()) {
+ log.trace("Rejected credential '{}', authentication is sole-factor and authenticator '{}' that "
+ + "created the credential should only be used as a second factor",
+ credential.getCredentialIdBase64Url(), toBase64OrUnknown(credential.getAaguid()));
+ }
+ return CredentialPolicyOutcome.REJECT;
+ }
+ }
+ return CredentialPolicyOutcome.ACCEPT;
+ }
+
+ /**
+ * Convert the value to Base64 URL encoding, or return "unknown" if there is an error.
+ *
+ * @param value the value to Base64 URL encode
+ * @return the Bas64 URL encoded string.
+ */
+ private String toBase64OrUnknown(@Nullable final byte[] value) {
+ if (value == null) {
+ return "unknown";
+ }
+ try {
+ return Base64Support.encodeURLSafe(value);
+ } catch (final EncodingException e) {
+ return "unknown";
+ }
+ }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
index c90bc91..7250576 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
@@ -309,6 +309,25 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
readLock.unlock();
}
}
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull public Optional<CredentialRegistration> getRegistrationByUserHandleAndCredentialId(
+ final ByteArray credentialId, final ByteArray userHandle) {
+ checkComponentActive();
+ final Lock readLock = lock.readLock();
+ try {
+ readLock.lock();
+ final Collection<CredentialRegistration> existingRegistrations = getRegistrationsByUserHandle(userHandle);
+ final Optional<CredentialRegistration> registration = existingRegistrations.stream()
+ .filter(credReg -> credentialId.equals(credReg.getCredential().getCredentialId()))
+ .findFirst();
+ assert registration != null;
+ return registration;
+ } finally {
+ readLock.unlock();
+ }
+ }
/** {@inheritDoc} */
@Override
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
index d6b9f22..b674f5d 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
@@ -18,6 +18,9 @@
<bean id="AbstractWebAuthnAuthenticationAction" scope="prototype" abstract="true"
p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.WebAuthnAuthenticationClientFactory') ?: getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"/>
+
+ <bean id="AbstractCredentialPolicyRule" scope="prototype" abstract="true"
+ p:fidoMetadataService="#{'false'.equals('%{idp.authn.webauthn.metadata.enabled:false}') ? null : getObject('shibboleth.authn.webauthn.DefaultWebAuthnFidoMetadataServiceFactory')}"/>
<!-- Flow beans -->
@@ -137,6 +140,20 @@
p:triggerEventOnNoCredentials="%{idp.authn.webauthn.signalEventOnNoCredentialsRegisteredForUserHandle:false}"
p:noCredentialsEventId="%{idp.authn.webauthn.userHandleNoRegisteredCredentialsEventId:NoCredentialsRegisteredForUserHandle}"/>
+ <bean id="CheckCredentialPolicy" parent="AbstractWebAuthnAuthenticationAction" scope="prototype"
+ class="net.shibboleth.idp.plugin.authn.webauthn.impl.CheckCredentialPolicy"
+ p:credentialRepository="#{getObject('shibboleth.authn.webauthn.CredentialRepositoryy') ?: getObject('shibboleth.authn.webauthn.DefaultCredentialRepository')}"
+ p:credentialPolicy="#{getObject('%{idp.authn.webauthn.credential.policy:shibboleth.authn.webauthn.ChainedCredentialPolicies}')}"
+ p:activationCondition="%{idp.authn.webauthn.credential.policy.enabled:false}"/>
+
+ <util:list id="shibboleth.authn.webauthn.ChainedCredentialPolicies">
+
+ <bean id="SecondFactorOnlyCredentialPolicyRule" parent="AbstractCredentialPolicyRule" scope="prototype"
+ class="net.shibboleth.idp.plugin.authn.webauthn.policy.impl.SecondFactorOnlyCredentialPolicyRule"
+ p:activationCondition="%{idp.authn.webauthn.registration.authenticator.policy.secondFactorOnly.enabled:true}"/>
+
+ </util:list>
+
<bean id="ValidateWebAuthnAssertion" scope="prototype"
class="net.shibboleth.idp.plugin.authn.webauthn.impl.ValidateWebAuthnAssertion"
p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.WebAuthnAuthenticationClientFactory') ?: getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"
@@ -145,6 +162,12 @@
p:populateAuditContextAction="#{%{idp.authn.webauthn.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('shibboleth.authn.webauthn.PopulateAuditContext') : null}"
p:writeAuditLogAction="#{%{idp.authn.webauthn.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('WriteAuthnAuditLog') : null}"/>
+ <!-- Error beans -->
+ <alias alias="WebAuthnErrorFunction" name="%{idp.authn.webauthn.errorMessageFunction:DefaultWebAuthnErrorFunction}" />
+
+ <bean id="DefaultWebAuthnErrorFunction" class="net.shibboleth.idp.plugin.authn.webauthn.context.navigate.WebAuthnErrorMessageLookupFunction" lazy-init="true"
+ p:genericMessageID="%{idp.authn.webauthn.genericMessageID:idp.webauthn.message}" />
+
<!-- Audit logging beans. -->
<!-- Default audit format and extractors -->
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
index bf832ed..330dd56 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
@@ -122,6 +122,7 @@
<evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.ui.context.RelyingPartyUIContext))" result="viewScope.rpUIContext" />
<evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationErrorContext))" result="viewScope.authenticationErrorContext" />
<evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationWarningContext))" result="viewScope.authenticationWarningContext" />
+ <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('WebAuthnErrorFunction')" result="viewScope.errorMessageFunction" />
<evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('WebAuthnCSPDigester')" result="requestScope.cspDigester" />
<evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('WebAuthnCSPNonce')" result="requestScope.cspNonce" />
<evaluate expression="T(net.shibboleth.shared.codec.HTMLEncoder)" result="viewScope.encoder" />
@@ -138,9 +139,14 @@
<!-- lookup credentials here so we can exit the process before validation if no registered credentials exist and
the authentication plugin has been configured to trigger a custom event. Useful for the usernameless flow. -->
<evaluate expression="LookupRegisteredCredentialsFromUserHandle"/>
+ <evaluate expression="CheckCredentialPolicy"/>
<evaluate expression="ValidateWebAuthnAssertion"/>
<evaluate expression="'proceed'" />
+
<transition on="proceed" to="proceed" />
+ <!-- Import here we backtrack to a suitable action. For example, regenerate the challenge and options but
+ we do not need a new username input etc. -->
+ <transition on="CredentialPolicyRejection" to="GenerateAuthenticationCeremonyOptions"/>
</action-state>
<bean-import resource="webauthn-beans.xml" />
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
index d08d8ed..bcc8984 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
@@ -108,6 +108,13 @@ idp.authn.webauthn.supportedPrincipals = \
# When using the default chained set of inspectors, give a comma seperated list of authenticators (by attestation GUIDs (AAGUID)) to tag as only allowed for second factor authentication
#idp.authn.webauthn.registration.authenticator.inspector.secondFactorOnlyAuthenticators =
+# Enable the credential/authenticator policy during authentication
+# idp.authn.webauthn.credential.policy.enabled = false
+# Set the credential repository to use, defaults to a chained set of policies
+#idp.authn.webauthn.credential.policy = shibboleth.authn.webauthn.ChainedCredentialPolicies
+# When using the default chained policy, should be enable the 'second-factor only' credential rule
+#idp.authn.webauthn.registration.authenticator.policy.secondFactorOnly.enabled = true
+
# Allow inline self-enrolment
#idp.authnwebauthn.registration.allowInline = true
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties
index ae45412..e84d3e1 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties
@@ -63,4 +63,7 @@ idp.webauthn.debug.register.request = Registration options
# Messages to report back to the user during registration
InvalidRegistration = Key registration unsuccessful
ValidRegistration = Key was registered successfully
-KeyRemoved = Key was removed successfully
\ No newline at end of file
+KeyRemoved = Key was removed successfully
+
+# Messages to report back to the user during authentication
+CredentialPolicyRejection = Credential was rejected by policy
\ No newline at end of file
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
index 547a6f8..c672cfa 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
@@ -92,6 +92,7 @@ import net.shibboleth.idp.ui.context.RelyingPartyUIContext;
import net.shibboleth.shared.annotation.constraint.NotEmpty;
import net.shibboleth.shared.codec.Base64Support;
import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.collection.CollectionSupport;
import net.shibboleth.shared.collection.Pair;
import net.shibboleth.shared.servlet.impl.HttpServletRequestResponseContext;
@@ -208,6 +209,7 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
*
* @param username the username
* @param display name the display name
+ * @param userHandleB64 the user handle base64 encoded
* @return the credential registration
*
* @throws Exception on error
@@ -215,6 +217,23 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
protected CredentialRegistration createCredentialRegistration(final String username, final String displayName,
final String userHandleB64) throws Exception {
+ return createCredentialRegistration(username, displayName, userHandleB64, null);
+ }
+
+ /**
+ * Create a credential registration with a new attestation response from the mock authenticator.
+ *
+ * @param username the username
+ * @param display name the display name
+ * @param userHandleB64 the user handle base64 encoded
+ * @param capabilities the capabilities of the authenticator that created this credential
+ * @return the credential registration
+ *
+ * @throws Exception on error
+ */
+ protected CredentialRegistration createCredentialRegistration(final String username, final String displayName,
+ final String userHandleB64, final Map<String,String> capabilities) throws Exception {
+
final var user = UserIdentity.builder()
.name(username)
.displayName(displayName)
@@ -245,6 +264,7 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
.withCredential(credential)
.withCredentialNickname("nickname")
.withDiscoverable(Optional.of(Boolean.TRUE))
+ .withAuthenticatorCapabilities(capabilities != null ? capabilities : CollectionSupport.emptyMap())
.withUserVerified(true)
.build();
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java
new file mode 100644
index 0000000..30f5da0
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java
@@ -0,0 +1,109 @@
+/*
+ * 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.webauthn.flow;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.webflow.context.ExternalContextHolder;
+import org.springframework.webflow.engine.impl.FlowExecutionImpl;
+import org.springframework.webflow.executor.FlowExecutionResult;
+import org.testng.annotations.Test;
+
+import com.yubico.webauthn.data.AuthenticatorAssertionResponse;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.SecondFactorOnlyAuthenticatorInspector;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.collection.Pair;
+
+
+/**
+ * Flow tests for the usernameless flow with active policies.
+ */
+ at ContextConfiguration(
+ locations = {
+ "classpath*:/META-INF/net.shibboleth.idp/postconfig.xml",
+ "classpath*:/net/shibboleth/idp/plugin/authn/webauthn/test-beans.xml", },
+ initializers = {
+ TestWebAuthnEnvironmentApplicationContextInitializer.class,
+ TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.class
+ }
+ )
+public class TestUsernameslessFlowWithPolicy extends AbstractWebAuthnFlowTest{
+
+ /** Flow ID. */
+ @Nonnull public static final String FLOW_ID = "authn/WebAuthn";
+
+ /**
+ * Constructor.
+ */
+ protected TestUsernameslessFlowWithPolicy() {
+ super(FLOW_ID, "proceed");
+ }
+
+ @SuppressWarnings("null")
+ @Test
+ public void testUsernamelessFlow_CredentialRejected_2FAOnly() throws Exception {
+
+ //Register a credential for use that is only suitable for 2FA
+ final CredentialRegistration registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,
+ Map.of(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME, "true"));
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+
+ final var prc = buildProfileRequestContext(false, false, null);
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ assertPublicKeyCredentialRequestOptions(prc, false, true);
+
+ // Do assertion validation half of flow
+
+ // Get the challenge that was set into the PublicKeyCredentialRequestOptions.
+ // otherwise we will end up signing a different one, which will provide its own test.
+ final WebAuthnAuthenticationContext authnContext = getWebAuthnAuthenticationContext(prc);
+ final ByteArray challenge = authnContext.getPublicKeyCredentialRequestOptions().getChallenge();
+
+ final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
+ assertionResponse = createAssertionReponseFrom(
+ registration.getCredential().getCredentialId().getBytes(), challenge.getBytes());
+
+ final String assertionResponseJson = jsonMapper.writeValueAsString(assertionResponse);
+
+ // Re-set external context to holder
+ ExternalContextHolder.setExternalContext(externalContext);
+ setHttpFormRequest("POST", Map.of(ExtractPublicKeyCredentialAssertionFromFormRequest.DEFAULT_PARAMETER_NAME,
+ assertionResponseJson));
+ externalContext.setEventId("proceed");
+ result.getSecond().setCurrentState("DisplayWebAuthnView");
+ result.getSecond().resume(externalContext);
+
+ // Policy failure will return the user to the webauthn login page
+ assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+
+ }
+
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
index c499db2..f183a05 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
@@ -46,7 +46,7 @@ public class TestWebAuthnEnvironmentApplicationContextInitializer
mock.setProperty("idp.authn.webauthn.relyingPartyId", "idp.example.com");
mock.setProperty("idp.csrf.enabled", "false");
mock.setProperty("idp.authn.webauthn.relyingPartyName", "Shibboleth");
- mock.setProperty("idp.service.logging.resource", "/logback-webauthn-test.xml");
+ mock.setProperty("idp.service.logging.resource", "/logback-webauthn-flow-test.xml");
mock.setProperty("idp.additionalProperties",
"/conf/ldap.properties, /conf/saml-nameid.properties, /conf/services.properties, /conf/admin/admin.properties, /conf/authn/authn.properties, /conf/c14n/subject-c14n.properties, /credentials/secrets.properties, /conf/sp/sp.properties");
applicationContext.getEnvironment().getPropertySources().addFirst(mock);
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.java
similarity index 62%
copy from webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
copy to webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.java
index c499db2..f7b9220 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.java
@@ -32,25 +32,20 @@ import net.shibboleth.shared.primitive.LoggerFactory;
* set to {@link Ordered#LOWEST_PRECEDENCE} or things blow up.</p>
*/
@Order(Ordered.LOWEST_PRECEDENCE)
-public class TestWebAuthnEnvironmentApplicationContextInitializer
+public class TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
/** Class logger. */
- @Nonnull private final Logger log = LoggerFactory.getLogger(TestWebAuthnEnvironmentApplicationContextInitializer.class);
+ @Nonnull private final Logger log = LoggerFactory.getLogger(TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.class);
/** {@inheritDoc} */
@Override public void initialize(@Nonnull final ConfigurableApplicationContext applicationContext) {
- final MockPropertySource mock = new MockPropertySource();
- mock.setProperty("idp.home", "classpath:/net/shibboleth/idp/module");
- mock.setProperty("idp.webflows", "classpath*:/flows");
- mock.setProperty("idp.authn.webauthn.relyingPartyId", "idp.example.com");
- mock.setProperty("idp.csrf.enabled", "false");
- mock.setProperty("idp.authn.webauthn.relyingPartyName", "Shibboleth");
- mock.setProperty("idp.service.logging.resource", "/logback-webauthn-test.xml");
- mock.setProperty("idp.additionalProperties",
- "/conf/ldap.properties, /conf/saml-nameid.properties, /conf/services.properties, /conf/admin/admin.properties, /conf/authn/authn.properties, /conf/c14n/subject-c14n.properties, /credentials/secrets.properties, /conf/sp/sp.properties");
+ final MockPropertySource mock = new MockPropertySource("usernameless-mock-properties");
+ mock.setProperty("idp.authn.webauthn.usernameless.enabled", "true");
+ mock.setProperty("idp.authn.webauthn.credential.policy.enabled", "true");
+ mock.setProperty("idp.authn.webauthn.registration.authenticator.policy.secondFactorOnly.enabled", "true");
applicationContext.getEnvironment().getPropertySources().addFirst(mock);
- log.info("Prepending properties '{}'", mock.getSource());
+ log.info("Prepending usernameless properties '{}'", mock.getSource());
}
}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
index 5ac1fe1..fd34308 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
@@ -333,7 +333,19 @@ public abstract class AbstractWebAuthnTest {
*
* @throws Exception on error
*/
- protected CredentialRegistration createCredentialRegistration() throws Exception {
+ protected CredentialRegistration createCredentialRegistration() throws Exception {
+ return createCredentialRegistration(null);
+ }
+
+ /**
+ * Create a credential registration with a new attestation response from the mock authenticator.
+ *
+ * @param capabilities the set of capabilities to add
+ * @return the credential registration
+ * @throws Exception on error
+ */
+ protected CredentialRegistration createCredentialRegistration(final Map<String,String> capabilities)
+ throws Exception {
final var user = UserIdentity.builder()
.name("jdoe")
@@ -365,6 +377,9 @@ public abstract class AbstractWebAuthnTest {
.withCredential(credential)
.withCredentialNickname("nickname")
.withDiscoverable(Optional.of(Boolean.TRUE))
+ .withAuthenticatorCapabilities(capabilities)
+ .withAaguid(attestation.getResponse().getParsedAuthenticatorData()
+ .getAttestedCredentialData().get().getAaguid().getBytes())
.withUserVerified(true)
.build();
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRuleTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRuleTest.java
new file mode 100644
index 0000000..220e09d
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRuleTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.webauthn.policy.impl;
+
+import static org.testng.Assert.assertTrue;
+
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.SecondFactorOnlyAuthenticatorInspector;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnTest;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy.CredentialPolicyOutcome;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Tests for {@link SecondFactorOnlyCredentialPolicyRule}.
+ */
+public class SecondFactorOnlyCredentialPolicyRuleTest extends AbstractWebAuthnTest {
+
+ private SecondFactorOnlyCredentialPolicyRule policy;
+
+ private CredentialRegistration credential;
+
+ @Override
+ @BeforeMethod
+ public void setup() throws Exception {
+ super.setup();
+ policy = new SecondFactorOnlyCredentialPolicyRule();
+ policy.setId("SecondFactorOnlyPolicy");
+ credential = createCredentialRegistration();
+
+ }
+
+ @Test
+ public void testAllowed() throws ComponentInitializationException {
+ policy.initialize();
+
+ final CredentialPolicyOutcome accepted = policy.evaluate(credential, prc);
+ assertTrue(accepted == CredentialPolicyOutcome.ACCEPT);
+
+ }
+
+ @Test
+ public void testRejected_IsSoleFactor() throws Exception {
+ credential = createCredentialRegistration(
+ CollectionSupport.singletonMap(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME, "true"));
+ policy.initialize();
+
+ final CredentialPolicyOutcome accepted = policy.evaluate(credential, prc);
+ assertTrue(accepted == CredentialPolicyOutcome.REJECT);
+
+ }
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
index 6111619..960fa30 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
@@ -198,4 +198,16 @@ public class InMemoryRegistrationStorage implements WebAuthnCredentialRepository
// TODO Auto-generated method stub
return false;
}
+
+ /** {@inheritDoc} */
+ @Override
+ public Optional<CredentialRegistration> getRegistrationByUserHandleAndCredentialId(final ByteArray credentialId,
+ final ByteArray userHandle) {
+ final Collection<CredentialRegistration> existingRegistrations = getRegistrationsByUserHandle(userHandle);
+ final Optional<CredentialRegistration> registration = existingRegistrations.stream()
+ .filter(credReg -> credentialId.equals(credReg.getCredential().getCredentialId()))
+ .findFirst();
+ assert registration != null;
+ return registration;
+ }
}
diff --git a/webauthn-impl/src/test/resources/logback-webauthn-flow-test.xml b/webauthn-impl/src/test/resources/logback-webauthn-flow-test.xml
new file mode 100644
index 0000000..d71e445
--- /dev/null
+++ b/webauthn-impl/src/test/resources/logback-webauthn-flow-test.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<configuration>
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+ <pattern>%level [%logger:%line] - %msg%n</pattern>
+ <charset>UTF-8</charset>
+ </encoder>
+ </appender>
+
+ <root>
+ <level value="WARN" />
+ <appender-ref ref="STDOUT" />
+ </root>
+
+ <logger name="net.shibboleth.idp.plugin.authn" level="TRACE" additivity="false">
+ <appender-ref ref="STDOUT" />
+ </logger>
+
+ <logger name="org.springframework.webflow" level="INFO" additivity="false">
+ <appender-ref ref="STDOUT" />
+ </logger>
+
+</configuration>
\ 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