[java-identity-provider] branch master updated: IDP-1391 - Add a service layer for password validators.

Scott Cantor cantor.2 at osu.edu
Thu Aug 8 10:34:19 EDT 2019


This is an automated email from the git hooks/post-receive script.

scantor pushed a commit to branch master
in repository java-identity-provider.

View the commit online:
http://git.shibboleth.net/view/?p=java-identity-provider.git;a=commit;h=9e4b294e97ca9d39182636b308b9310abb6a47d5

The following commit(s) were added to refs/heads/master by this push:
       new  9e4b294   IDP-1391 - Add a service layer for password validators.
9e4b294 is described below

commit 9e4b294e97ca9d39182636b308b9310abb6a47d5
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Thu Aug 8 10:34:15 2019 -0400

    IDP-1391 - Add a service layer for password validators.
    
    Redesign credential validation with new APIs.
    Convert existing behavior and internal config.
---
 ...stractUsernamePasswordCredentialValidator.java} | 239 +++++++++---------
 .../shibboleth/idp/authn/CredentialValidator.java  | 122 +++++++++
 ...ainstJAAS.java => JAASCredentialValidator.java} | 194 +++++++-------
 ...beros.java => KerberosCredentialValidator.java} | 142 ++++++-----
 ...ainstLDAP.java => LDAPCredentialValidator.java} | 153 +++++------
 .../idp/authn/impl/ValidateCredentials.java        | 279 +++++++++++++++++++++
 .../ValidateUsernamePasswordAgainstJAASTest.java   | 100 +++++---
 .../ValidateUsernamePasswordAgainstLDAPTest.java   | 100 ++++++--
 .../system/flows/authn/password-authn-beans.xml    |  80 +++---
 .../system/flows/authn/password-authn-flow.xml     |   2 +-
 10 files changed, 938 insertions(+), 473 deletions(-)

diff --git a/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AbstractUsernamePasswordValidationAction.java b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AbstractUsernamePasswordCredentialValidator.java
similarity index 51%
rename from idp-authn-api/src/main/java/net/shibboleth/idp/authn/AbstractUsernamePasswordValidationAction.java
rename to idp-authn-api/src/main/java/net/shibboleth/idp/authn/AbstractUsernamePasswordCredentialValidator.java
index 5b44018..21b79e2 100644
--- a/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AbstractUsernamePasswordValidationAction.java
+++ b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AbstractUsernamePasswordCredentialValidator.java
@@ -17,48 +17,46 @@
 
 package net.shibboleth.idp.authn;
 
+import java.util.function.Function;
 import java.util.regex.Pattern;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import javax.security.auth.Subject;
+import javax.security.auth.login.LoginException;
 
 import net.shibboleth.idp.authn.context.AuthenticationContext;
 import net.shibboleth.idp.authn.context.UsernamePasswordContext;
 import net.shibboleth.idp.authn.principal.PasswordPrincipal;
 import net.shibboleth.idp.authn.principal.UsernamePrincipal;
 import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.component.AbstractIdentifiedInitializableComponent;
 import net.shibboleth.utilities.java.support.component.ComponentSupport;
+import net.shibboleth.utilities.java.support.logic.Constraint;
 
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * An abstract action that checks for a {@link UsernamePasswordContext} and produces an
- * {@link net.shibboleth.idp.authn.AuthenticationResult} based on that identity by invoking
- * a subclass method.
- *  
- * <p>Lockout behavior can be enabled by injecting an {@link AccountLockoutManager}</p>
- *  
- * @event {@link org.opensaml.profile.action.EventIds#PROCEED_EVENT_ID}
- * @event {@link AuthnEventIds#INVALID_CREDENTIALS}
- * @event {@link AuthnEventIds#NO_CREDENTIALS}
- * @event {@link AuthnEventIds#ACCOUNT_LOCKED}
- * @post If AuthenticationContext.getSubcontext(UsernamePasswordContext.class) != null, then
- * an {@link net.shibboleth.idp.authn.AuthenticationResult} is saved to the {@link AuthenticationContext} on a
- * successful login. On a failed login, the
- * {@link AbstractValidationAction#handleError(ProfileRequestContext, AuthenticationContext, Exception, String)}
- * method is called.
+ * An abstract {@link CredentialValidator} that checks for a {@link UsernamePasswordContext} and delegates
+ * to subclasses to produce an {@link net.shibboleth.idp.authn.AuthenticationResult}.
+ * 
+ * @since 4.0.0
  */
-public abstract class AbstractUsernamePasswordValidationAction extends AbstractValidationAction {
+public abstract class AbstractUsernamePasswordCredentialValidator extends AbstractIdentifiedInitializableComponent
+        implements CredentialValidator {
 
     /** Default prefix for metrics. */
     @Nonnull @NotEmpty private static final String DEFAULT_METRIC_NAME = "net.shibboleth.idp.authn.password"; 
     
     /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(AbstractUsernamePasswordValidationAction.class);
+    @Nonnull private final Logger log = LoggerFactory.getLogger(AbstractUsernamePasswordCredentialValidator.class);
 
+    /** Lookup strategy for UP context. */
+    @Nonnull private Function<AuthenticationContext,UsernamePasswordContext> usernamePasswordContextLookupStrategy;
+    
     /** Whether to save the password in the Java Subject's private credentials. */
     private boolean savePasswordToCredentialSet;
     
@@ -68,16 +66,32 @@ public abstract class AbstractUsernamePasswordValidationAction extends AbstractV
     /** A regular expression to apply for acceptance testing. */
     @Nullable private Pattern matchExpression;
     
-    /** Optional lockout management interface. */
-    @Nullable private AccountLockoutManager lockoutManager;
-    
-    /** UsernamePasswordContext containing the credentials to validate. */
-    @Nullable private UsernamePasswordContext upContext;
+    /** Cached log prefix. */
+    @Nullable private String logPrefix;
     
     /** Constructor. */
-    public AbstractUsernamePasswordValidationAction() {
+    public AbstractUsernamePasswordCredentialValidator() {
+        usernamePasswordContextLookupStrategy = new ChildContextLookup<>(UsernamePasswordContext.class);
         removeContextAfterValidation = true;
-        setMetricName(DEFAULT_METRIC_NAME);
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    public void setId(final String id) {
+        super.setId(id);
+    }
+    
+    /**
+     * Set the lookup strategy to locate the {@link UsernamePasswordContext}.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setUsernamePasswordContextLookupStrategy(
+            @Nonnull final Function<AuthenticationContext,UsernamePasswordContext> strategy) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        
+        usernamePasswordContextLookupStrategy = Constraint.isNotNull(strategy,
+                "UsernamePasswordContextLookupStrategy cannot be null");
     }
     
     /**
@@ -107,8 +121,6 @@ public abstract class AbstractUsernamePasswordValidationAction extends AbstractV
      * <p>Defaults to true</p>
      * 
      * @return whether to remove the context after successful validation
-     * 
-     * @since 3.3.0
      */
     public boolean removeContextAfterValidation() {
         return removeContextAfterValidation;
@@ -119,8 +131,6 @@ public abstract class AbstractUsernamePasswordValidationAction extends AbstractV
      * successfully validated.
      * 
      * @param flag  flag to set
-     * 
-     * @since 3.3.0
      */
     public void setRemoveContextAfterValidation(final boolean flag) {
         ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
@@ -139,129 +149,110 @@ public abstract class AbstractUsernamePasswordValidationAction extends AbstractV
         matchExpression = expression;
     }
     
-    /**
-     * Get an account lockout management component.
-     * 
-     * @return lockout manager
-     */
-    @Nullable public AccountLockoutManager getLockoutManager() {
-        return lockoutManager;
-    }
-    
-    /**
-     * Set an account lockout management component.
-     * 
-     * @param manager lockout manager
-     */
-    public void setLockoutManager(@Nullable final AccountLockoutManager manager) {
-        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
-        
-        lockoutManager = manager;
-    }
-    
-    /**
-     * Get the {@link UsernamePasswordContext} to validate. 
-     * 
-     * @return context to validate
-     */
-    @Nullable public UsernamePasswordContext getUsernamePasswordContext() {
-        return upContext;
-    }
-
     /** {@inheritDoc} */
     @Override
-    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
+    public Subject validate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext,
+            @Nullable final WarningHandler warningHandler,
+            @Nullable final ErrorHandler errorHandler) throws Exception {
         
-        if (!super.doPreExecute(profileRequestContext, authenticationContext)) {
-            return false;
-        }
-                
-        upContext = authenticationContext.getSubcontext(UsernamePasswordContext.class);
+        final UsernamePasswordContext upContext = getUsernamePasswordContext(authenticationContext);
         if (upContext == null) {
-            log.info("{} No UsernamePasswordContext available within authentication context", getLogPrefix());
-            handleError(profileRequestContext, authenticationContext, "NoCredentials", AuthnEventIds.NO_CREDENTIALS);
-            recordFailure();
-            return false;
+            log.info("{} No UsernamePasswordContext available", getLogPrefix());
+            if (errorHandler != null) {
+                errorHandler.handleError(profileRequestContext, authenticationContext, (String) null,
+                        AuthnEventIds.NO_CREDENTIALS);
+            }
+            throw new LoginException(AuthnEventIds.NO_CREDENTIALS);
         } else if (upContext.getUsername() == null) {
             log.info("{} No username available within UsernamePasswordContext", getLogPrefix());
-            handleError(profileRequestContext, authenticationContext, "NoCredentials", AuthnEventIds.NO_CREDENTIALS);
-            recordFailure();
-            return false;
+            if (errorHandler != null) {
+                errorHandler.handleError(profileRequestContext, authenticationContext, (String) null,
+                        AuthnEventIds.NO_CREDENTIALS);
+            }
+            throw new LoginException(AuthnEventIds.NO_CREDENTIALS);
         } else if (upContext.getPassword() == null) {
             log.info("{} No password available within UsernamePasswordContext", getLogPrefix());
-            handleError(profileRequestContext, authenticationContext, AuthnEventIds.INVALID_CREDENTIALS,
-                    AuthnEventIds.INVALID_CREDENTIALS);
-            recordFailure();
-            return false;
+            if (errorHandler != null) {
+                errorHandler.handleError(profileRequestContext, authenticationContext, (String) null,
+                        AuthnEventIds.INVALID_CREDENTIALS);
+            }
+            throw new LoginException(AuthnEventIds.INVALID_CREDENTIALS);
         }
         
         if (matchExpression != null && !matchExpression.matcher(upContext.getUsername()).matches()) {
             log.debug("{} Username '{}' did not match expression", getLogPrefix(), upContext.getUsername());
-            handleError(profileRequestContext, authenticationContext, AuthnEventIds.INVALID_CREDENTIALS,
-                    AuthnEventIds.INVALID_CREDENTIALS);
-            recordFailure();
-            return false;
-        }
-        
-        if (lockoutManager != null && lockoutManager.check(profileRequestContext)) {
-            log.info("{} Account for '{}' is locked out, aborting authentication", getLogPrefix(), 
-                    upContext.getUsername());
-            handleError(profileRequestContext, authenticationContext, AuthnEventIds.ACCOUNT_LOCKED,
-                    AuthnEventIds.ACCOUNT_LOCKED);
-            recordFailure();
-            return false;
+            return null;
         }
-        
-        return true;
+                
+        return doValidate(profileRequestContext, authenticationContext, upContext, warningHandler, errorHandler);
     }
 
-    /** {@inheritDoc} */
-    @Override
-    @Nonnull protected Subject populateSubject(@Nonnull final Subject subject) {
-        subject.getPrincipals().add(new UsernamePrincipal(upContext.getUsername()));
+    /**
+     * Override method for subclasses to use to perform the actual validation.
+     * 
+     * @param profileRequestContext profile request context
+     * @param authenticationContext authentication context
+     * @param usernamePasswordContext the username/password to validate
+     * @param warningHandler optional warning handler interface
+     * @param errorHandler optional error handler interface
+     * 
+     * @return the validated result, or null if inapplicable
+     * 
+     * @throws Exception if an error occurs
+     */
+    @Nullable protected abstract Subject doValidate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext,
+            @Nonnull final UsernamePasswordContext usernamePasswordContext,
+            @Nullable final WarningHandler warningHandler,
+            @Nullable final ErrorHandler errorHandler) throws Exception;
+
+    /**
+     * Get the {@link UsernamePasswordContext} to validate. 
+     * 
+     * @param authenticationContext parent context
+     * 
+     * @return context to validate
+     */
+    @Nullable protected UsernamePasswordContext getUsernamePasswordContext(
+            @Nonnull final AuthenticationContext authenticationContext) {
+        return usernamePasswordContextLookupStrategy.apply(authenticationContext);
+    }
+
+    /**
+     * Decorate the subject with "standard" content from the validation
+     * and clean up as instructed.
+     * 
+     * @param subject the subject being returned
+     * @param usernamePasswordContext the username/password validated
+     * 
+     * @return the decorated subject
+     */
+    @Nonnull protected Subject populateSubject(@Nonnull final Subject subject,
+            @Nonnull final UsernamePasswordContext usernamePasswordContext) {
+        subject.getPrincipals().add(new UsernamePrincipal(usernamePasswordContext.getUsername()));
         if (savePasswordToCredentialSet) {
-            subject.getPrivateCredentials().add(new PasswordPrincipal(upContext.getPassword()));
+            subject.getPrivateCredentials().add(new PasswordPrincipal(usernamePasswordContext.getPassword()));
         }
         
         if (removeContextAfterValidation) {
-            upContext.getParent().removeSubcontext(upContext);
-            upContext.setPassword(null);
-            upContext = null;
+            usernamePasswordContext.getParent().removeSubcontext(usernamePasswordContext);
+            usernamePasswordContext.setPassword(null);
         }
         
         return subject;
     }
-    
-    /**
-     * Record a successful authentication attempt against the configured counter,
-     * optionally clearing account lockout state.
-     * 
-     * @param profileRequestContext current profile request context
-     * 
-     * @since 3.3.0
-     */
-    protected void recordSuccess(@Nonnull final ProfileRequestContext profileRequestContext) {
-        recordSuccess();
-        if (lockoutManager != null) {
-            lockoutManager.clear(profileRequestContext);
-        }
-    }
 
     /**
-     * Record a failed authentication attempt against the configured counter,
-     * optionally incrementing the account lockout counter.
-     * 
-     * @param profileRequestContext current profile request context
-     * @param inc true iff lockout counter should be incremented
+     * Return a prefix for logging messages for this component.
      * 
-     * @since 3.3.0
+     * @return a string for insertion at the beginning of any log messages
      */
-    protected void recordFailure(@Nonnull final ProfileRequestContext profileRequestContext, final boolean inc) {
-        recordFailure();
-        if (inc && lockoutManager != null) {
-            lockoutManager.increment(profileRequestContext);
+    @Nonnull @NotEmpty protected String getLogPrefix() {
+        if (logPrefix == null) {
+            logPrefix = "Credential Validator " + (getId() != null ? getId() : "(unknown)") + ":";
         }
+        return logPrefix;
     }
-
+    
 }
\ No newline at end of file
diff --git a/idp-authn-api/src/main/java/net/shibboleth/idp/authn/CredentialValidator.java b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/CredentialValidator.java
new file mode 100644
index 0000000..aa28d95
--- /dev/null
+++ b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/CredentialValidator.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You 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;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
+import javax.security.auth.Subject;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.component.IdentifiableComponent;
+
+/**
+ * High-level API for validating credentials and producing a Java Subject as a result.
+ * 
+ * <p>This is more or less what JAAS does but with a simpler interface adapted better
+ * to the IdP's needs. Predominantly for password validation scenarios but the interface
+ * is not specific to that use case.</p>
+ * 
+ * <p>Instances of this interface must be stateless.</p>
+ * 
+ * @since 4.0.0
+ */
+ at ThreadSafe
+public interface CredentialValidator extends IdentifiableComponent {
+
+    /**
+     * Validate any credentials found in a supported form within the input context tree
+     * and produce a {@link Subject} as the outcome.
+     * 
+     * <p>A null result is used to signal that validation was not attempted.</p>
+     * 
+     * @param profileRequestContext profile request context
+     * @param authenticationContext authentication context
+     * @param warningHandler optional warning handler interface
+     * @param errorHandler optional error handler interface
+     * 
+     * @return result of a successful validation, or null
+     * 
+     * @throws Exception when validation is unsuccessful due to a failed attempt
+     */
+    @Nullable Subject validate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext,
+            @Nullable final WarningHandler warningHandler,
+            @Nullable final ErrorHandler errorHandler) throws Exception;
+    
+    /**
+     * Interface to use to report warnings to the caller.
+     */
+    @ThreadSafe
+    public interface WarningHandler {
+        
+        /**
+         * Reports a warning state to the caller.
+         * 
+         * <p>Warnings are an indication that authentication may have succeeded but with some information
+         * worth capturing.</p>
+         * 
+         * @param profileRequestContext the current profile request context
+         * @param authenticationContext the current authentication context
+         * @param message to report
+         * @param eventId a default webflow event to report as the result of the calling action
+         */
+        void handleWarning(@Nonnull final ProfileRequestContext profileRequestContext,
+                @Nonnull final AuthenticationContext authenticationContext, @Nullable final String message,
+                @Nonnull @NotEmpty final String eventId);
+    }
+    
+    /**
+     * Interface to use to report errors to the caller.
+     */
+    @ThreadSafe
+    public interface ErrorHandler {
+        
+        /**
+         * Reports an error state to the caller.
+         * 
+         * <p>Errors should never be reported as part of a successful login.</p>
+         * 
+         * @param profileRequestContext the current profile request context
+         * @param authenticationContext the current authentication context
+         * @param e exception to report
+         * @param eventId a default webflow event to report as the result of the calling action
+         */
+        void handleError(@Nonnull final ProfileRequestContext profileRequestContext,
+                @Nonnull final AuthenticationContext authenticationContext, @Nonnull final Exception e,
+                @Nonnull @NotEmpty final String eventId);
+        
+        /**
+         * Reports an error state to the caller.
+         * 
+         * <p>Errors should never be reported as part of a successful login.</p>
+         * 
+         * @param profileRequestContext the current profile request context
+         * @param authenticationContext the current authentication context
+         * @param message to report
+         * @param eventId a default webflow event to report as the result of the calling action
+         */
+        void handleError(@Nonnull final ProfileRequestContext profileRequestContext,
+                @Nonnull final AuthenticationContext authenticationContext, @Nullable final String message,
+                @Nonnull @NotEmpty final String eventId);
+    }
+
+}
\ No newline at end of file
diff --git a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstJAAS.java b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/JAASCredentialValidator.java
similarity index 70%
rename from idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstJAAS.java
rename to idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/JAASCredentialValidator.java
index cdf2d4e..598d71f 100644
--- a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstJAAS.java
+++ b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/JAASCredentialValidator.java
@@ -27,20 +27,21 @@ import java.util.function.Function;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
 import javax.security.auth.Subject;
 import javax.security.auth.callback.Callback;
 import javax.security.auth.callback.CallbackHandler;
-import javax.security.auth.callback.LanguageCallback;
 import javax.security.auth.callback.NameCallback;
 import javax.security.auth.callback.PasswordCallback;
 import javax.security.auth.callback.UnsupportedCallbackException;
 import javax.security.auth.login.Configuration;
 import javax.security.auth.login.LoginException;
 
-import net.shibboleth.idp.authn.AbstractUsernamePasswordValidationAction;
+import net.shibboleth.idp.authn.AbstractUsernamePasswordCredentialValidator;
 import net.shibboleth.idp.authn.AuthnEventIds;
 import net.shibboleth.idp.authn.context.AuthenticationContext;
 import net.shibboleth.idp.authn.context.RequestedPrincipalContext;
+import net.shibboleth.idp.authn.context.UsernamePasswordContext;
 import net.shibboleth.idp.authn.principal.PrincipalEvalPredicate;
 import net.shibboleth.idp.authn.principal.PrincipalEvalPredicateFactory;
 import net.shibboleth.idp.authn.principal.PrincipalSupportingComponent;
@@ -50,36 +51,23 @@ import net.shibboleth.utilities.java.support.collection.Pair;
 import net.shibboleth.utilities.java.support.component.ComponentSupport;
 import net.shibboleth.utilities.java.support.primitive.StringSupport;
 
-import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * An action that checks for a {@link net.shibboleth.idp.authn.context.UsernamePasswordContext} and directly produces an
- * {@link net.shibboleth.idp.authn.AuthenticationResult} based on that identity by invoking a JAAS configuration.
+ * A password validator that authenticates against JAAS.
  * 
- * <p>Various optional properties are supported to control the JAAS configuration process.</p>
- *  
- * @event {@link org.opensaml.profile.action.EventIds#PROCEED_EVENT_ID}
- * @event {@link AuthnEventIds#NO_CREDENTIALS}
- * @event {@link AuthnEventIds#INVALID_CREDENTIALS}
- * @event {@link AuthnEventIds#REQUEST_UNSUPPORTED}
- * @pre <pre>ProfileRequestContext.getSubcontext(AuthenticationContext.class).getAttemptedFlow() != null</pre>
- * @post If AuthenticationContext.getSubcontext(UsernamePasswordContext.class) != null, then
- * an {@link net.shibboleth.idp.authn.AuthenticationResult} is saved to the {@link AuthenticationContext} on a
- * successful login. On a failed login, the
- * {@link net.shibboleth.idp.authn.AbstractValidationAction#handleError(ProfileRequestContext, AuthenticationContext,
- *    Exception, String)}
- * method is called.
+ * <p>Support for complex chaining of JAAS modules remains supported but should be
+ * avoided in favor of the new support for chaining validators in most cases.</p>
+ * 
+ * @since 4.0.0
  */
-public class ValidateUsernamePasswordAgainstJAAS extends AbstractUsernamePasswordValidationAction {
-
-    /** Default prefix for metrics. */
-    @Nonnull @NotEmpty private static final String DEFAULT_METRIC_NAME = "net.shibboleth.idp.authn";
+ at ThreadSafe
+public class JAASCredentialValidator extends AbstractUsernamePasswordCredentialValidator {
     
     /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(ValidateUsernamePasswordAgainstJAAS.class);
+    @Nonnull private final Logger log = LoggerFactory.getLogger(JAASCredentialValidator.class);
     
     /** Type of JAAS Configuration to instantiate. */
     @Nullable private String loginConfigType;
@@ -93,17 +81,8 @@ public class ValidateUsernamePasswordAgainstJAAS extends AbstractUsernamePasswor
     /** Strategy function to dynamically derive the login config(s) to use. */
     @Nullable private Function<ProfileRequestContext,Collection<Pair<String,Subject>>> loginConfigStrategy;
     
-    /** Saved off context. */
-    @Nullable private RequestedPrincipalContext requestedPrincipalCtx;
-    
-    /** Tracks any principals derived from the login configuration to add to the Subject. */
-    @Nullable private Subject derivedSubject;
-    
-    /** Tracker for current login config for reporting. */
-    @Nullable private String currentLoginConfigName;
-    
     /** Constructor. */
-    public ValidateUsernamePasswordAgainstJAAS() {
+    public JAASCredentialValidator() {
         // For compatibility with V2.
         loginConfigurations = Collections.singletonList(new Pair<String,Subject>("ShibUserPassAuth", null));
     }
@@ -207,82 +186,77 @@ public class ValidateUsernamePasswordAgainstJAAS extends AbstractUsernamePasswor
     
     /** {@inheritDoc} */
     @Override
-    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
-        
-        if (!super.doPreExecute(profileRequestContext, authenticationContext)) {
-            return false;
-        }
-        
-        requestedPrincipalCtx = authenticationContext.getSubcontext(RequestedPrincipalContext.class);
-        return true;
-    }
-    
-    /** {@inheritDoc} */
-    @Override
-    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
+    @Nullable protected Subject doValidate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext,
+            @Nonnull final UsernamePasswordContext usernamePasswordContext,
+            @Nullable final WarningHandler warningHandler,
+            @Nullable final ErrorHandler errorHandler) throws Exception {
 
+        final RequestedPrincipalContext requestedPrincipalCtx =
+                authenticationContext.getSubcontext(RequestedPrincipalContext.class);
+        
         final Collection<Pair<String,Subject>> configs;
         if (loginConfigStrategy != null) {
             configs = loginConfigStrategy.apply(profileRequestContext);
         } else {
             configs = loginConfigurations;
         }
-        
-        boolean eventSignaled = false;
+
+        Exception caughtException = null;
         
         for (final Pair<String,Subject> loginConfig : configs) {
             
-            if (!isAcceptable(authenticationContext, loginConfig.getFirst(), loginConfig.getSecond())) {
+            if (!isAcceptable(requestedPrincipalCtx, loginConfig.getFirst(), loginConfig.getSecond())) {
                 continue;
             }
-            
+
+            final String currentLoginConfigName = loginConfig.getFirst();
+
             try {
-                currentLoginConfigName = loginConfig.getFirst();
                 log.debug("{} Attempting to authenticate user '{}' via '{}'", getLogPrefix(),
-                        getUsernamePasswordContext().getUsername(), currentLoginConfigName);
-                authenticate(currentLoginConfigName);
+                        usernamePasswordContext.getUsername(), currentLoginConfigName);
+                final Subject subject = authenticate(currentLoginConfigName, usernamePasswordContext);
                 log.info("{} Login by '{}' via '{}' succeeded", getLogPrefix(),
-                        getUsernamePasswordContext().getUsername(), currentLoginConfigName);
-                recordSuccess(profileRequestContext);
-                derivedSubject = loginConfig.getSecond();
-                buildAuthenticationResult(profileRequestContext, authenticationContext);
-                ActionSupport.buildProceedEvent(profileRequestContext);
-                return;
+                        usernamePasswordContext.getUsername(), currentLoginConfigName);
+                return populateSubject(subject, loginConfig.getSecond(), usernamePasswordContext);
             } catch (final LoginException e){ 
-                log.info("{} Login by '{}' via '{}' failed", getLogPrefix(), getUsernamePasswordContext().getUsername(),
+                log.info("{} Login by '{}' via '{}' failed", getLogPrefix(), usernamePasswordContext.getUsername(),
                         currentLoginConfigName, e);
-                handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.INVALID_CREDENTIALS);
-                recordFailure(profileRequestContext, true);
-                eventSignaled = true;
+                if (errorHandler != null) {
+                    errorHandler.handleError(profileRequestContext, authenticationContext, e,
+                            AuthnEventIds.INVALID_CREDENTIALS);
+                }
+                caughtException = e;
             } catch (final Exception e) {
                 log.warn("{} Login by '{}' via '{}' produced exception", getLogPrefix(),
-                        getUsernamePasswordContext().getUsername(), currentLoginConfigName, e);
-                handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.AUTHN_EXCEPTION);
-                recordFailure(profileRequestContext, false);
-                eventSignaled = true;
+                        usernamePasswordContext.getUsername(), currentLoginConfigName, e);
+                if (errorHandler != null) {
+                    errorHandler.handleError(profileRequestContext, authenticationContext, e,
+                            AuthnEventIds.AUTHN_EXCEPTION);
+                }
+                caughtException = e;
             }
         }
-    
-        if (!eventSignaled) {
-            log.warn("{} No JAAS application configurations are available or acceptable for use", getLogPrefix());
-            handleError(profileRequestContext, authenticationContext, "RequestUnsupported",
-                    AuthnEventIds.REQUEST_UNSUPPORTED);
+        
+        if (caughtException == null) {
+            log.info("{} No JAAS application configurations are available or acceptable for use", getLogPrefix());
+            return null;
         }
+        
+        throw caughtException;
     }
     
     /**
      * Checks a particular JAAS configuration and principal collection for suitability.
      * 
-     * @param authenticationContext the authentication context
+     * @param requestedPrincipalCtx the relevant context
      * @param configName name of JAAS config
      * @param subject collection of custom principals to check, embedded in a subject
      * 
      * @return true iff the request does not specify requirements or the principal collection is empty
      *  or the combination is acceptable
      */
-    private boolean isAcceptable(@Nonnull final AuthenticationContext authenticationContext,
+    private boolean isAcceptable(@Nullable final RequestedPrincipalContext requestedPrincipalCtx,
             @Nonnull @NotEmpty final String configName, @Nullable final Subject subject) {
         
         if (subject != null && requestedPrincipalCtx != null && requestedPrincipalCtx.getOperator() != null) {
@@ -326,12 +300,16 @@ public class ValidateUsernamePasswordAgainstJAAS extends AbstractUsernamePasswor
      * Create a JAAS configuration and attempt a login with it.
      * 
      * @param loginConfigName the application name to use
+     * @param usernamePasswordContext input context
+     * 
+     * @return the JAAS result
      * 
      * @throws LoginException if the JAAS login process fails
      * @throws NoSuchAlgorithmException if a JAAS configuration cannot be created
      */
-    private void authenticate(@Nonnull @NotEmpty final String loginConfigName)
-            throws LoginException, NoSuchAlgorithmException {
+    @Nonnull private Subject authenticate(@Nonnull @NotEmpty final String loginConfigName,
+            @Nonnull final UsernamePasswordContext usernamePasswordContext)
+                    throws LoginException, NoSuchAlgorithmException {
         
         final javax.security.auth.login.LoginContext jaasLoginCtx;
         
@@ -340,32 +318,35 @@ public class ValidateUsernamePasswordAgainstJAAS extends AbstractUsernamePasswor
                     getLoginConfigType(), getLoginConfigParameters().getClass().getName());
             final Configuration loginConfig =
                     Configuration.getInstance(getLoginConfigType(), getLoginConfigParameters());
-            jaasLoginCtx = new javax.security.auth.login.LoginContext(loginConfigName, getSubject(),
-                    new SimpleCallbackHandler(), loginConfig);
+            jaasLoginCtx = new javax.security.auth.login.LoginContext(loginConfigName, null,
+                    new SimpleCallbackHandler(usernamePasswordContext), loginConfig);
         } else {
             log.debug("{} Using system JAAS configuration", getLogPrefix());
-            jaasLoginCtx = new javax.security.auth.login.LoginContext(loginConfigName, getSubject(),
-                    new SimpleCallbackHandler());
+            jaasLoginCtx = new javax.security.auth.login.LoginContext(loginConfigName, null,
+                    new SimpleCallbackHandler(usernamePasswordContext));
         }
 
         jaasLoginCtx.login();
+        
+        return jaasLoginCtx.getSubject();
     }
 
-    /** {@inheritDoc} */
-    @Override
-    @Nonnull protected Subject populateSubject(@Nonnull final Subject subject) {
-        
-        final Subject theSubject = super.populateSubject(subject);
+    /**
+     * Finish decorating the result.
+     * 
+     * @param subject the JAAS result
+     * @param derivedSubject container for additional principals
+     * @param usernamePasswordContext input context
+     * 
+     * @return final result
+     */
+    @Nonnull protected Subject populateSubject(@Nonnull final Subject subject,
+            @Nullable final Subject derivedSubject, @Nonnull final UsernamePasswordContext usernamePasswordContext) {
+
         if (derivedSubject != null) {
-            theSubject.getPrincipals().addAll(derivedSubject.getPrincipals());
+            subject.getPrincipals().addAll(derivedSubject.getPrincipals());
         }
-        return theSubject;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    @Nonnull @NotEmpty public String getMetricName() {
-        return super.getMetricName() + '.' + currentLoginConfigName;
+        return super.populateSubject(subject, usernamePasswordContext);
     }
         
     /**
@@ -376,6 +357,18 @@ public class ValidateUsernamePasswordAgainstJAAS extends AbstractUsernamePasswor
      */
     protected class SimpleCallbackHandler implements CallbackHandler {
 
+        /** Context for call. */
+        @Nonnull private final UsernamePasswordContext context;
+        
+        /**
+         * Constructor.
+         *
+         * @param usernamePasswordContext input context
+         */
+        public SimpleCallbackHandler(@Nonnull final UsernamePasswordContext usernamePasswordContext) {
+            context = usernamePasswordContext;
+        }
+        
         /**
          * Handle a callback.
          * 
@@ -393,17 +386,10 @@ public class ValidateUsernamePasswordAgainstJAAS extends AbstractUsernamePasswor
             for (final Callback cb : callbacks) {
                 if (cb instanceof NameCallback) {
                     final NameCallback ncb = (NameCallback) cb;
-                    ncb.setName(getUsernamePasswordContext().getUsername());
+                    ncb.setName(context.getUsername());
                 } else if (cb instanceof PasswordCallback) {
                     final PasswordCallback pcb = (PasswordCallback) cb;
-                    pcb.setPassword(getUsernamePasswordContext().getPassword().toCharArray());
-                } else if (cb instanceof LanguageCallback) {
-                    if (getHttpServletRequest() != null) {
-                        final LanguageCallback lcb = (LanguageCallback) cb;
-                        lcb.setLocale(getHttpServletRequest().getLocale());
-                    } else {
-                        log.warn("{} Language callback invoked, no HttpServletRequest available", getLogPrefix());
-                    }
+                    pcb.setPassword(context.getPassword().toCharArray());
                 }
             }
         }
diff --git a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstKerberos.java b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/KerberosCredentialValidator.java
similarity index 72%
rename from idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstKerberos.java
rename to idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/KerberosCredentialValidator.java
index 5070266..ca6b46d 100644
--- a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstKerberos.java
+++ b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/KerberosCredentialValidator.java
@@ -24,6 +24,7 @@ import java.util.Map;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
 import javax.security.auth.Subject;
 import javax.security.auth.callback.Callback;
 import javax.security.auth.callback.CallbackHandler;
@@ -33,9 +34,10 @@ import javax.security.auth.callback.UnsupportedCallbackException;
 import javax.security.auth.login.LoginException;
 import javax.security.auth.spi.LoginModule;
 
-import net.shibboleth.idp.authn.AbstractUsernamePasswordValidationAction;
+import net.shibboleth.idp.authn.AbstractUsernamePasswordCredentialValidator;
 import net.shibboleth.idp.authn.AuthnEventIds;
 import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.UsernamePasswordContext;
 import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
 import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
@@ -54,26 +56,15 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * An action that checks for a {@link net.shibboleth.idp.authn.context.UsernamePasswordContext} and directly produces an
- * {@link net.shibboleth.idp.authn.AuthenticationResult} based on that identity by acquiring
- * a TGT and optional service ticket from Kerberos.
- *  
- * @event {@link org.opensaml.profile.action.EventIds#PROCEED_EVENT_ID}
- * @event {@link AuthnEventIds#AUTHN_EXCEPTION}
- * @event {@link AuthnEventIds#INVALID_CREDENTIALS}
- * @pre <pre>ProfileRequestContext.getSubcontext(AuthenticationContext.class, false).getAttemptedFlow() != null</pre>
- * @post If AuthenticationContext.getSubcontext(UsernamePasswordContext.class, false) != null, then
- * an {@link net.shibboleth.idp.authn.AuthenticationResult} is saved to the {@link AuthenticationContext} on a
- * successful login. On a failed login, the {@link net.shibboleth.idp.authn.AbstractValidationAction#handleError(
- * ProfileRequestContext, AuthenticationContext, Exception, String)} method is called.
+ * A password validator that authenticates against Kerberos natively, with optional service ticket verification.
+ * 
+ * @since 4.0.0
  */
-public class ValidateUsernamePasswordAgainstKerberos extends AbstractUsernamePasswordValidationAction {
-    
-    /** Default prefix for metrics. */
-    @Nonnull @NotEmpty private static final String DEFAULT_METRIC_NAME = "net.shibboleth.idp.authn.krb5";
+ at ThreadSafe
+public class KerberosCredentialValidator extends AbstractUsernamePasswordCredentialValidator {
     
     /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(ValidateUsernamePasswordAgainstKerberos.class);
+    @Nonnull private final Logger log = LoggerFactory.getLogger(KerberosCredentialValidator.class);
 
     /** Class name of JAAS LoginModule to acquire Kerberos credentials. */
     @NonnullAfterInit @NotEmpty private String loginModuleClassName;
@@ -97,9 +88,8 @@ public class ValidateUsernamePasswordAgainstKerberos extends AbstractUsernamePas
     @NonnullAfterInit private Map<String,String> serverOptions;
     
     /** Constructor. */
-    public ValidateUsernamePasswordAgainstKerberos() {
+    public KerberosCredentialValidator() {
         loginModuleClassName = "com.sun.security.auth.module.Krb5LoginModule";
-        setMetricName(DEFAULT_METRIC_NAME);
     }
     
     /**
@@ -189,67 +179,81 @@ public class ValidateUsernamePasswordAgainstKerberos extends AbstractUsernamePas
     
     /** {@inheritDoc} */
     @Override
-    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
+    protected Subject doValidate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext,
+            @Nonnull final UsernamePasswordContext usernamePasswordContext,
+            @Nullable final WarningHandler warningHandler, @Nullable final ErrorHandler errorHandler) throws Exception {
+        
+        String eventToSignal = AuthnEventIds.AUTHN_EXCEPTION;
         
         try {
-            final LoginModule clientLoginModule = (LoginModule) Class.forName(loginModuleClassName).
-                    getDeclaredConstructor().newInstance();
-            clientLoginModule.initialize(getSubject(), new SimpleCallbackHandler(), new HashMap(), clientOptions);
-            if (!clientLoginModule.login() || !clientLoginModule.commit()) {
-                clientLoginModule.abort();
-                throw new LoginException("Login module reported failure");
-            }
-            
-            // We don't call logout, since that would destroy the contents of the Subject.
-            
-            if (servicePrincipal != null) {
-                verifyKDC();
+            try {
+                final Subject subject = new Subject();
+                final LoginModule clientLoginModule = (LoginModule) Class.forName(loginModuleClassName).
+                        getDeclaredConstructor().newInstance();
+                clientLoginModule.initialize(subject, new SimpleCallbackHandler(usernamePasswordContext), new HashMap(),
+                        clientOptions);
+                if (!clientLoginModule.login() || !clientLoginModule.commit()) {
+                    clientLoginModule.abort();
+                    throw new LoginException("Login module reported failure");
+                }
+                
+                // We don't call logout, since that would destroy the contents of the Subject.
+                
+                if (servicePrincipal != null) {
+                    log.debug("{} TGT acquired for {}, " +
+                            "attempting to verify authenticity of TGT using service principal {}",
+                            getLogPrefix(), usernamePasswordContext.getUsername(), servicePrincipal);
+                    verifyKDC(subject);
+                }
+                
+                log.info("{} Login by '{}' succeeded", getLogPrefix(), usernamePasswordContext.getUsername());
+                return populateSubject(subject, usernamePasswordContext);
+            } catch (final InstantiationException | IllegalAccessException | ClassNotFoundException e) {
+                log.error("{} Unable to instantiate JAAS module for Kerberos", getLogPrefix(), e);
+                throw e;
+            } catch (final LoginException e) {
+                log.info("{} Login by {} failed", getLogPrefix(), usernamePasswordContext.getUsername(), e);
+                eventToSignal = AuthnEventIds.INVALID_CREDENTIALS;
+                throw e;
+            } catch(final GSSException e) {
+                log.warn("{} Login by {} failed during GSS context establishment to verify KDC", getLogPrefix(),
+                        usernamePasswordContext.getUsername(), e);
+                eventToSignal = AuthnEventIds.INVALID_CREDENTIALS;
+                throw e;
+            } catch (final Exception e) {
+                log.warn("{} Login by {} produced unknown exception", getLogPrefix(),
+                        usernamePasswordContext.getUsername(), e);
+                throw e;
             }
-            
-            log.info("{} Login by '{}' succeeded", getLogPrefix(), getUsernamePasswordContext().getUsername());
-            recordSuccess(profileRequestContext);
-            buildAuthenticationResult(profileRequestContext, authenticationContext);
-        } catch (final InstantiationException | IllegalAccessException | ClassNotFoundException e) {
-            log.error("{} Unable to instantiate JAAS module for Kerberos", getLogPrefix(), e);
-            handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.AUTHN_EXCEPTION);
-            recordFailure(profileRequestContext, false);
-        } catch (final LoginException e) {
-            log.info("{} Login by {} failed", getLogPrefix(), getUsernamePasswordContext().getUsername(), e);
-            handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.INVALID_CREDENTIALS);
-            recordFailure(profileRequestContext, true);
-        } catch(final GSSException e) {
-            log.warn("{} Login by {} failed during GSS context establishment to verify KDC", getLogPrefix(),
-                    getUsernamePasswordContext().getUsername(), e);
-            handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.INVALID_CREDENTIALS);
-            recordFailure(profileRequestContext, false);
         } catch (final Exception e) {
-            log.warn("{} Login by {} produced unknown exception", getLogPrefix(),
-                    getUsernamePasswordContext().getUsername(), e);
-            handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.AUTHN_EXCEPTION);
-            recordFailure(profileRequestContext, false);
+            if (errorHandler != null) {
+                errorHandler.handleError(profileRequestContext, authenticationContext, e, eventToSignal);
+            }
+            throw e;
         }
     }
 
     /** {@inheritDoc} */
     @Override
-    @Nonnull protected Subject populateSubject(@Nonnull final Subject subject) {
+    @Nonnull protected Subject populateSubject(@Nonnull final Subject subject,
+            @Nonnull final UsernamePasswordContext usernamePasswordContext) {
 
         if (!preserveTicket) {
             subject.getPrivateCredentials().clear();
         }
         
-        return super.populateSubject(subject);
+        return super.populateSubject(subject, usernamePasswordContext);
     }
 
     /**
      * Use credentials to acquire and verify a service ticket.
      * 
+     * @param subject client identity
+     * 
      * @throws Exception if an error occurs
      */
-    private void verifyKDC() throws Exception {
-        log.debug("{} TGT acquired for {}, attempting to verify authenticity of TGT using service principal {}",
-                getLogPrefix(), getUsernamePasswordContext().getUsername(), servicePrincipal);
+    private void verifyKDC(@Nonnull final Subject subject) throws Exception {
         
         final Oid mechOid = new Oid("1.2.840.113554.1.2.2");
         
@@ -273,7 +277,7 @@ public class ValidateUsernamePasswordAgainstKerberos extends AbstractUsernamePas
             
             // The GSS context initiation has to be performed as a privileged action with the client subject
             // so that the null credential above indicating the default credentials pulls from the JAAS subject.
-            final byte[] token = Subject.doAs(getSubject(), new PrivilegedExceptionAction<byte[]>() {
+            final byte[] token = Subject.doAs(subject, new PrivilegedExceptionAction<byte[]>() {
                 public byte[] run() throws GSSException {
                     final byte[] token = new byte[0];
                     // This is a one pass context initialization.
@@ -323,6 +327,18 @@ public class ValidateUsernamePasswordAgainstKerberos extends AbstractUsernamePas
      * This handler only supports {@link NameCallback} and {@link PasswordCallback}.
      */
     private class SimpleCallbackHandler implements CallbackHandler {
+        
+        /** Context for call. */
+        @Nonnull private final UsernamePasswordContext context;
+        
+        /**
+         * Constructor.
+         *
+         * @param usernamePasswordContext input context
+         */
+        public SimpleCallbackHandler(@Nonnull final UsernamePasswordContext usernamePasswordContext) {
+            context = usernamePasswordContext;
+        }
 
         /**
          * Handle a callback.
@@ -341,10 +357,10 @@ public class ValidateUsernamePasswordAgainstKerberos extends AbstractUsernamePas
             for (final Callback cb : callbacks) {
                 if (cb instanceof NameCallback) {
                     final NameCallback ncb = (NameCallback) cb;
-                    ncb.setName(getUsernamePasswordContext().getUsername());
+                    ncb.setName(context.getUsername());
                 } else if (cb instanceof PasswordCallback) {
                     final PasswordCallback pcb = (PasswordCallback) cb;
-                    pcb.setPassword(getUsernamePasswordContext().getPassword().toCharArray());
+                    pcb.setPassword(context.getPassword().toCharArray());
                 }
             }
         }
diff --git a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstLDAP.java b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/LDAPCredentialValidator.java
similarity index 51%
rename from idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstLDAP.java
rename to idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/LDAPCredentialValidator.java
index cbae93b..1af9ee5 100644
--- a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstLDAP.java
+++ b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/LDAPCredentialValidator.java
@@ -19,14 +19,15 @@ package net.shibboleth.idp.authn.impl;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
 import javax.security.auth.Subject;
 
-import net.shibboleth.idp.authn.AbstractUsernamePasswordValidationAction;
+import net.shibboleth.idp.authn.AbstractUsernamePasswordCredentialValidator;
 import net.shibboleth.idp.authn.AuthnEventIds;
 import net.shibboleth.idp.authn.context.AuthenticationContext;
 import net.shibboleth.idp.authn.context.LDAPResponseContext;
+import net.shibboleth.idp.authn.context.UsernamePasswordContext;
 import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
-import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
 import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
 import net.shibboleth.utilities.java.support.component.ComponentSupport;
 import net.shibboleth.utilities.java.support.logic.Constraint;
@@ -47,44 +48,21 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * An action that checks for a {@link net.shibboleth.idp.authn.context.UsernamePasswordContext} and directly produces an
- * {@link net.shibboleth.idp.authn.AuthenticationResult} based on that identity by authenticating against an LDAP.
+ * A password validator that authenticates against LDAP natively.
  * 
- * @event {@link org.opensaml.profile.action.EventIds#PROCEED_EVENT_ID}
- * @event {@link AuthnEventIds#AUTHN_EXCEPTION}
- * @event {@link AuthnEventIds#ACCOUNT_WARNING}
- * @event {@link AuthnEventIds#ACCOUNT_ERROR}
- * @event {@link AuthnEventIds#INVALID_CREDENTIALS}
- * @pre <pre>
- * ProfileRequestContext.getSubcontext(AuthenticationContext.class).getAttemptedFlow() != null
- * </pre>
- * @post If AuthenticationContext.getSubcontext(UsernamePasswordContext.class) != null, then an
- *       {@link net.shibboleth.idp.authn.AuthenticationResult} is saved to the {@link AuthenticationContext} on a
- *       successful login. On a failed login, the
- *       {@link net.shibboleth.idp.authn.AbstractValidationAction#handleError(ProfileRequestContext,
- *       AuthenticationContext, String, String)} method is called.
+ * @since 4.0.0
  */
-public class ValidateUsernamePasswordAgainstLDAP extends AbstractUsernamePasswordValidationAction {
-
-    /** Default prefix for metrics. */
-    @Nonnull @NotEmpty private static final String DEFAULT_METRIC_NAME = "net.shibboleth.idp.authn.ldap";
+ at ThreadSafe
+public class LDAPCredentialValidator extends AbstractUsernamePasswordCredentialValidator {
     
     /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(ValidateUsernamePasswordAgainstLDAP.class);
+    @Nonnull private final Logger log = LoggerFactory.getLogger(LDAPCredentialValidator.class);
 
     /** LDAP authenticator. */
     @Nonnull private Authenticator authenticator;
 
     /** Attributes to return from authentication. */
     @Nullable private String[] returnAttributes;
-
-    /** Authentication response associated with the login. */
-    @Nullable private AuthenticationResponse response;
-
-    /** Constructor. */
-    public ValidateUsernamePasswordAgainstLDAP() {
-        setMetricName(DEFAULT_METRIC_NAME);
-    }
     
     /**
      * Returns the authenticator.
@@ -127,7 +105,8 @@ public class ValidateUsernamePasswordAgainstLDAP extends AbstractUsernamePasswor
     }
 
     /** {@inheritDoc} */
-    @Override protected void doInitialize() throws ComponentInitializationException {
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
         super.doInitialize();
 
         if (authenticator == null) {
@@ -135,72 +114,98 @@ public class ValidateUsernamePasswordAgainstLDAP extends AbstractUsernamePasswor
         }
     }
 
+// Checkstyle: CyclomaticComplexity OFF    
     /** {@inheritDoc} */
-    @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
+    @Override
+    @Nullable protected Subject doValidate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext,
+            @Nonnull final UsernamePasswordContext usernamePasswordContext,
+            @Nullable final WarningHandler warningHandler,
+            @Nullable final ErrorHandler errorHandler) throws Exception {
+        
+        final String username = usernamePasswordContext.getUsername();
+        
+        String eventToSignal = AuthnEventIds.INVALID_CREDENTIALS;
+        
+        // The error handling is squonky. We log at info to generically record the failure.
+        // Known conditions are not explicitly logged but are wrapped with an exception and
+        // reported out to the caller. Last ditch, an exception is logged on warn and then
+        // reported out.
+        
         try {
-            log.debug("{} Attempting to authenticate user {}", getLogPrefix(), getUsernamePasswordContext()
-                    .getUsername());
+            log.debug("{} Attempting to authenticate user {}", getLogPrefix(), username);
             final VelocityContext context = new VelocityContext();
-            context.put("usernamePasswordContext", getUsernamePasswordContext());
+            context.put("usernamePasswordContext", usernamePasswordContext);
             final AuthenticationRequest request =
-                    new AuthenticationRequest(new User(getUsernamePasswordContext().getUsername(), context),
-                            new Credential(getUsernamePasswordContext().getPassword()), returnAttributes);
-            response = authenticator.authenticate(request);
+                    new AuthenticationRequest(new User(username, context),
+                            new Credential(usernamePasswordContext.getPassword()), returnAttributes);
+            final AuthenticationResponse response = authenticator.authenticate(request);
             log.trace("{} Authentication response {}", getLogPrefix(), response);
             if (response.getResult()) {
-                log.info("{} Login by '{}' succeeded", getLogPrefix(), getUsernamePasswordContext().getUsername());
-                recordSuccess(profileRequestContext);
-                authenticationContext.getSubcontext(LDAPResponseContext.class, true)
-                        .setAuthenticationResponse(response);
+                log.info("{} Login by '{}' succeeded", getLogPrefix(), username);
+                authenticationContext.getSubcontext(
+                        LDAPResponseContext.class, true).setAuthenticationResponse(response);
                 if (response.getAccountState() != null) {
                     final AccountState.Error error = response.getAccountState().getError();
-                    handleWarning(
-                            profileRequestContext,
-                            authenticationContext,
-                            String.format("%s:%s:%s", error != null ? error : "ACCOUNT_WARNING",
-                                    response.getResultCode(), response.getMessage()), AuthnEventIds.ACCOUNT_WARNING);
+                    if (warningHandler != null) {
+                        warningHandler.handleWarning(
+                                profileRequestContext,
+                                authenticationContext,
+                                String.format("%s:%s:%s", error != null ? error : "ACCOUNT_WARNING",
+                                        response.getResultCode(), response.getMessage()),
+                                AuthnEventIds.ACCOUNT_WARNING);
+                    }
                 }
-                buildAuthenticationResult(profileRequestContext, authenticationContext);
+                return populateSubject(usernamePasswordContext, response);
             } else {
-                log.info("{} Login by '{}' failed", getLogPrefix(), getUsernamePasswordContext().getUsername());
-                authenticationContext.getSubcontext(LDAPResponseContext.class, true)
-                        .setAuthenticationResponse(response);
+                log.info("{} Login by '{}' failed", getLogPrefix(), username);
+                authenticationContext.getSubcontext(
+                        LDAPResponseContext.class, true).setAuthenticationResponse(response);
                 if (AuthenticationResultCode.DN_RESOLUTION_FAILURE == response.getAuthenticationResultCode()
                         || AuthenticationResultCode.INVALID_CREDENTIAL == response.getAuthenticationResultCode()) {
-                    handleError(profileRequestContext, authenticationContext,
-                            String.format("%s:%s", response.getAuthenticationResultCode(), response.getMessage()),
-                            AuthnEventIds.INVALID_CREDENTIALS);
-                    recordFailure(profileRequestContext, true);
+                    throw new LdapException(
+                            String.format("%s:%s", response.getAuthenticationResultCode(), response.getMessage()));
                 } else if (response.getAccountState() != null) {
                     final AccountState state = response.getAccountState();
-                    handleError(profileRequestContext, authenticationContext, String.format("%s:%s:%s",
-                            state.getError(), response.getResultCode(), response.getMessage()),
-                            AuthnEventIds.ACCOUNT_ERROR);
-                    recordFailure(profileRequestContext, true);
+                    eventToSignal = AuthnEventIds.ACCOUNT_ERROR;
+                    throw new LdapException(
+                            String.format("%s:%s:%s", state.getError(), response.getResultCode(), response.getMessage())
+                            );
                 } else if (response.getResultCode() == ResultCode.INVALID_CREDENTIALS) {
-                    handleError(profileRequestContext, authenticationContext,
-                            String.format("%s:%s", response.getResultCode(), response.getMessage()),
-                            AuthnEventIds.INVALID_CREDENTIALS);
-                    recordFailure(profileRequestContext, true);
+                    throw new LdapException(String.format("%s:%s", response.getResultCode(), response.getMessage()));
                 } else {
-                    throw new LdapException(response.getMessage(), response.getResultCode(), response.getMatchedDn(),
+                    eventToSignal = AuthnEventIds.AUTHN_EXCEPTION;
+                    final LdapException e =
+                            new LdapException(response.getMessage(), response.getResultCode(), response.getMatchedDn(),
                             response.getControls(), response.getReferralURLs(), response.getMessageId());
+                    log.warn("{} Login by {} produced exception", getLogPrefix(), username, e);
+                    throw e;
                 }
             }
         } catch (final LdapException e) {
-            log.warn("{} Login by {} produced exception", getLogPrefix(), getUsernamePasswordContext().getUsername(),
-                    e);
-            recordFailure(profileRequestContext, false);
-            handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.AUTHN_EXCEPTION);
+            if (errorHandler != null) {
+                errorHandler.handleError(profileRequestContext, authenticationContext, e, eventToSignal);
+            }
+            throw e;
         }
     }
+// Checkstyle: CyclomaticComplexity ON
 
-    /** {@inheritDoc} */
-    @Override @Nonnull protected Subject populateSubject(@Nonnull final Subject subject) {
+    /**
+     * Builds a new {@link Subject} populated with the necessary data.
+     * 
+     * @param usernamePasswordContext input context
+     * @param ldapResponse LDAP response data
+     * 
+     * @return the subject to return
+     */
+    @Nonnull protected Subject populateSubject(@Nonnull final UsernamePasswordContext usernamePasswordContext,
+            @Nonnull final AuthenticationResponse ldapResponse) {
+        
+        final Subject subject = new Subject();
         subject.getPrincipals().add(
-                new LdapPrincipal(getUsernamePasswordContext().getUsername(), response.getLdapEntry()));
-        return super.populateSubject(subject);
+                new LdapPrincipal(usernamePasswordContext.getUsername(), ldapResponse.getLdapEntry()));
+        return super.populateSubject(subject, usernamePasswordContext);
     }
 
-}
+}
\ No newline at end of file
diff --git a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateCredentials.java b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateCredentials.java
new file mode 100644
index 0000000..10647c5
--- /dev/null
+++ b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/ValidateCredentials.java
@@ -0,0 +1,279 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You 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.impl;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.security.auth.Subject;
+
+import net.shibboleth.idp.authn.AbstractValidationAction;
+import net.shibboleth.idp.authn.AccountLockoutManager;
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.CredentialValidator;
+import net.shibboleth.idp.authn.CredentialValidator.ErrorHandler;
+import net.shibboleth.idp.authn.CredentialValidator.WarningHandler;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullElements;
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.component.ComponentSupport;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Predicates;
+import com.google.common.collect.Collections2;
+
+/**
+ * An action that processes a list of {@link CredentialValidator} objects to produce an {@link AuthenticationResult}.
+ *  
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event others on error
+ * @pre <pre>ProfileRequestContext.getSubcontext(AuthenticationContext.class).getAttemptedFlow() != null</pre>
+ * 
+ * @since 4.0.0
+ */
+public class ValidateCredentials extends AbstractValidationAction implements WarningHandler, ErrorHandler {
+
+    /** Default prefix for metrics. */
+    @Nonnull @NotEmpty private static final String DEFAULT_METRIC_NAME = "net.shibboleth.idp.authn";
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(ValidateCredentials.class);
+    
+    /** Ordered list of validators. */
+    @Nonnull @NonnullElements private List<CredentialValidator> credentialValidators;
+    
+    /** Whether all validators must succeed. */
+    private boolean requireAll;
+
+    /** Optional lockout management interface. */
+    @Nullable private AccountLockoutManager lockoutManager;
+
+    /** Results from successful validators. */
+    @Nonnull @NonnullElements private Collection<Subject> results;
+    
+    /** Currently executing validator. */
+    @Nullable private CredentialValidator currentValidator;
+
+    /** Tracks whether a warning event was signaled. */
+    private boolean warningSignaled;
+
+    /** Tracks whether an error event was signaled. */
+    private boolean errorSignaled;
+    
+    /** Constructor. */
+    public ValidateCredentials() {
+        setMetricName(DEFAULT_METRIC_NAME);
+        credentialValidators = Collections.emptyList();
+        results = new ArrayList<>(1);
+    }
+    
+    /**
+     * Set an account lockout management component.
+     * 
+     * @param manager lockout manager
+     */
+    public void setLockoutManager(@Nullable final AccountLockoutManager manager) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        
+        lockoutManager = manager;
+    }
+    
+    /**
+     * Set the list of validators to use.
+     * 
+     * @param validators validators to use
+     */
+    public void setValidators(@Nonnull @NonnullElements final List<CredentialValidator> validators) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        Constraint.isNotNull(validators, "Validators list cannot be null");
+        
+        credentialValidators = new ArrayList<>(Collections2.filter(validators, Predicates.notNull()));
+    }
+    
+    /**
+     * Set whether to execute and require success from all configured validators,
+     * or stop at the first successful result.
+     * 
+     * @param flag flag to set
+     */
+    public void setRequireAll(final boolean flag) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        
+        requireAll = flag;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull @NotEmpty public String getMetricName() {
+        return super.getMetricName() + '.' + currentValidator.getId();
+    }
+       
+    /** {@inheritDoc} */
+    @Override
+    public void handleWarning(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext, @Nullable final String message,
+            @Nonnull @NotEmpty final String eventId) {
+        warningSignaled = true;
+        super.handleWarning(profileRequestContext, authenticationContext, message, eventId);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void handleError(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext, @Nullable final String message,
+            @Nonnull @NotEmpty final String eventId) {
+        errorSignaled = true;
+        super.handleError(profileRequestContext, authenticationContext, message, eventId);
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    public void handleError(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext, @Nonnull final Exception e,
+            @Nonnull @NotEmpty final String eventId) {
+        errorSignaled = true;
+        super.handleError(profileRequestContext, authenticationContext, e, eventId);
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        if (!super.doPreExecute(profileRequestContext, authenticationContext)) {
+            return false;
+        }
+        
+        if (authenticationContext.getAttemptedFlow() == null) {
+            log.info("{} No attempted flow within authentication context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        return true;
+    }
+
+// Checkstyle: CyclomaticComplexity OFF
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        if (lockoutManager != null && lockoutManager.check(profileRequestContext)) {
+            log.info("{} Account locked out, aborting authentication", getLogPrefix());
+            handleError(profileRequestContext, authenticationContext, AuthnEventIds.ACCOUNT_LOCKED,
+                    AuthnEventIds.ACCOUNT_LOCKED);
+            return;
+        }
+        
+        for (final CredentialValidator validator : credentialValidators) {
+            log.trace("{} Attempting credential validation via {}", getLogPrefix(), validator.getId());
+            
+            currentValidator = validator;
+            
+            try {
+                final Subject subject =
+                        currentValidator.validate(profileRequestContext, authenticationContext, this, this);
+                if (subject == null) {
+                    // Ignored, so try next one.
+                    continue;
+                }
+                
+                // Add the result to the list and record it.
+                results.add(subject);
+                
+                if (!requireAll) {
+                    recordSuccess(profileRequestContext);
+                    buildAuthenticationResult(profileRequestContext, authenticationContext);
+                    if (!warningSignaled) {
+                        ActionSupport.buildProceedEvent(profileRequestContext);
+                    }
+                    return;
+                }
+            } catch (final Exception e) {
+                recordFailure();
+                if (!errorSignaled) {
+                    super.handleError(profileRequestContext, authenticationContext, e, AuthnEventIds.AUTHN_EXCEPTION);
+                    errorSignaled = true;
+                }
+            }
+        }
+        
+        // If all must pass, and all passed, and at least one did something, then that's also success.
+        if (requireAll && !errorSignaled && !results.isEmpty()) {
+            recordSuccess(profileRequestContext);
+            buildAuthenticationResult(profileRequestContext, authenticationContext);
+            if (!warningSignaled) {
+                ActionSupport.buildProceedEvent(profileRequestContext);
+            }
+            return;
+        }
+
+        // If failure, then we may need to bump a lockout count if one of them outright
+        // failed. Failure could also just mean nothing was attempted.
+        
+        if (errorSignaled) {
+            if (lockoutManager != null) {
+                lockoutManager.increment(profileRequestContext);
+            }
+        } else {
+            log.warn("{} No validators were available or usable", getLogPrefix());
+            handleError(profileRequestContext, authenticationContext, AuthnEventIds.REQUEST_UNSUPPORTED,
+                    AuthnEventIds.REQUEST_UNSUPPORTED);
+        }
+    }
+// Checkstyle: CyclomaticComplexity ON
+    
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull protected Subject populateSubject(@Nonnull final Subject subject) {
+        
+        for (final Subject s : results) {
+            subject.getPrincipals().addAll(s.getPrincipals());
+            subject.getPublicCredentials().addAll(s.getPublicCredentials());
+            subject.getPrivateCredentials().addAll(s.getPrivateCredentials());
+        }
+        
+        return subject;
+    }
+
+    /**
+     * Record a successful authentication attempt against the configured counter,
+     * optionally clearing account lockout state.
+     * 
+     * @param profileRequestContext current profile request context
+     */
+    protected void recordSuccess(@Nonnull final ProfileRequestContext profileRequestContext) {
+        recordSuccess();
+        if (lockoutManager != null) {
+            lockoutManager.clear(profileRequestContext);
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstJAASTest.java b/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstJAASTest.java
index 87ba381..3757665 100644
--- a/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstJAASTest.java
+++ b/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstJAASTest.java
@@ -57,12 +57,14 @@ import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
 import com.unboundid.ldap.listener.InMemoryListenerConfig;
 import com.unboundid.ldap.sdk.LDAPException;
 
-/** {@link ValidateUsernamePasswordAgainstJAAS} unit test. */
+/** Unit test for JAAS validation. */
 public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationContextTest {
 
     private static final String DATA_PATH = "src/test/resources/net/shibboleth/idp/authn/impl/";
     
-    private ValidateUsernamePasswordAgainstJAAS action;
+    private JAASCredentialValidator validator;
+    
+    private ValidateCredentials action;
 
     private InMemoryDirectoryServer directoryServer;
 
@@ -91,7 +93,11 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
     @BeforeMethod public void setUp() throws Exception {
         super.setUp();
 
-        action = new ValidateUsernamePasswordAgainstJAAS();
+        validator = new JAASCredentialValidator();
+        validator.setId("jaastest");
+        
+        action = new ValidateCredentials();
+        action.setValidators(Collections.singletonList(validator));
         
         final Map<String,Collection<String>> mappings = new HashMap<>();
         mappings.put("UnknownUsername", Collections.singleton("DN_RESOLUTION_FAILURE"));
@@ -102,6 +108,7 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
     }
 
     @Test public void testMissingFlow() throws Exception {
+        validator.initialize();
         action.initialize();
 
         final Event event = action.execute(src);
@@ -110,6 +117,8 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
 
     @Test public void testMissingUser() throws Exception {
         prc.getSubcontext(AuthenticationContext.class).setAttemptedFlow(authenticationFlows.get(0));
+        
+        validator.initialize();
         action.initialize();
 
         final Event event = action.execute(src);
@@ -120,6 +129,8 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
         ac.getSubcontext(UsernamePasswordContext.class, true);
+        
+        validator.initialize();
         action.initialize();
 
         final Event event = action.execute(src);
@@ -132,6 +143,8 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
 
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
+        
+        validator.initialize();
         action.initialize();
 
         doExtract(prc);
@@ -149,11 +162,12 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
 
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setLoginConfigNames(Collections.singletonList("ShibBadAuth"));
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigNames(Collections.singletonList("ShibBadAuth"));
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -178,12 +192,13 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         rpc.setOperator("exact");
         rpc.setRequestedPrincipals(Collections.<Principal>singletonList(new TestPrincipal("test1")));
 
-        action.setLoginConfigurations(Collections.singletonList(new Pair<String,Collection<Principal>>("ShibUserPassAuth",
+        validator.setLoginConfigurations(Collections.singletonList(new Pair<String,Collection<Principal>>("ShibUserPassAuth",
                 Collections.<Principal>singletonList(new TestPrincipal("test2")))));
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -200,13 +215,15 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         ac.setAttemptedFlow(authenticationFlows.get(0));
         ac.getSubcontext(UsernamePasswordContext.class, true);
         
-        action.setMatchExpression(Pattern.compile("foo.+"));
+        validator.setMatchExpression(Pattern.compile("foo.+"));
+        validator.initialize();
+        
         action.initialize();
         
         doExtract(prc);
 
         final Event event = action.execute(src);
-        ActionTestingSupport.assertEvent(event, AuthnEventIds.INVALID_CREDENTIALS);
+        ActionTestingSupport.assertEvent(event, AuthnEventIds.REQUEST_UNSUPPORTED);
     }
 
     @Test public void testBadUsername() throws Exception {
@@ -215,10 +232,11 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
 
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
+        
+        validator.initialize();
         action.initialize();
 
         doExtract(prc);
@@ -237,10 +255,11 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
 
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -260,10 +279,11 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
 
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -284,11 +304,12 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
 
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
-        action.setRemoveContextAfterValidation(false);
+        validator.setRemoveContextAfterValidation(false);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -315,12 +336,13 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         rpc.setOperator("exact");
         rpc.setRequestedPrincipals(Collections.<Principal>singletonList(new TestPrincipal("test1")));
 
-        action.setLoginConfigurations(Collections.singletonList(new Pair<String,Collection<Principal>>("ShibUserPassAuth",
+        validator.setLoginConfigurations(Collections.singletonList(new Pair<String,Collection<Principal>>("ShibUserPassAuth",
                 Collections.<Principal>singletonList(new TestPrincipal("test1")))));
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -343,11 +365,12 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
 
-        action.setLoginConfigNames(Arrays.asList("ShibBadAuth", "ShibUserPassAuth"));
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigNames(Arrays.asList("ShibBadAuth", "ShibUserPassAuth"));
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -368,11 +391,12 @@ public class ValidateUsernamePasswordAgainstJAASTest extends BaseAuthenticationC
         final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
 
-        action.setLoginConfigType("JavaLoginConfig");
-        System.out.println(getCurrentDir());
-        action.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
+        validator.setLoginConfigType("JavaLoginConfig");
+        validator.setLoginConfigParameters(new URIParameter(URISupport.fileURIFromAbsolutePath(getCurrentDir()
                 + '/' + DATA_PATH + "jaas.config")));
-        action.setMatchExpression(Pattern.compile(".+_THE_.+"));
+        validator.setMatchExpression(Pattern.compile(".+_THE_.+"));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
diff --git a/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstLDAPTest.java b/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstLDAPTest.java
index f26924a..8175e77 100644
--- a/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstLDAPTest.java
+++ b/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/ValidateUsernamePasswordAgainstLDAPTest.java
@@ -63,12 +63,14 @@ import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
 import com.unboundid.ldap.listener.InMemoryListenerConfig;
 import com.unboundid.ldap.sdk.LDAPException;
 
-/** {@link ValidateUsernamePasswordAgainstLDAP} unit test. */
+/** Unit test for LDAP credential validation. */
 public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationContextTest {
 
     private static final String DATA_PATH = "src/test/resources/net/shibboleth/idp/authn/impl/";
 
-    private ValidateUsernamePasswordAgainstLDAP action;
+    private LDAPCredentialValidator validator;
+    
+    private ValidateCredentials action;
 
     private InMemoryDirectoryServer directoryServer;
 
@@ -117,20 +119,25 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
     @BeforeMethod public void setUp() throws Exception {
         super.setUp();
 
-        action = new ValidateUsernamePasswordAgainstLDAP();
+        validator = new LDAPCredentialValidator();
+        validator.setId("ldaptest");
+        
+        action = new ValidateCredentials();
+        action.setValidators(Collections.singletonList(validator));
 
-        Map<String, Collection<String>> mappings = new HashMap<>();
+        final Map<String, Collection<String>> mappings = new HashMap<>();
         mappings.put("UnknownUsername", Collections.singleton("DN_RESOLUTION_FAILURE"));
         mappings.put("InvalidPassword", Collections.singleton("INVALID_CREDENTIALS"));
         mappings.put("ExpiringPassword", Collections.singleton("ACCOUNT_WARNING"));
         mappings.put("ExpiredPassword", Arrays.asList("PASSWORD_EXPIRED", "CHANGE_AFTER_RESET"));
         action.setClassifiedMessages(mappings);
-
         action.setHttpServletRequest(new MockHttpServletRequest());
     }
 
     @Test public void testMissingFlow() throws Exception {
-        action.setAuthenticator(authenticator);
+        validator.setAuthenticator(authenticator);
+        validator.initialize();
+        
         action.initialize();
 
         final Event event = action.execute(src);
@@ -139,7 +146,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
     @Test public void testMissingUser() throws Exception {
         prc.getSubcontext(AuthenticationContext.class).setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(authenticator);
+
+        validator.setAuthenticator(authenticator);
+        validator.initialize();
+        
         action.initialize();
 
         final Event event = action.execute(src);
@@ -150,7 +160,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
         ac.getSubcontext(UsernamePasswordContext.class, true);
-        action.setAuthenticator(authenticator);
+        
+        validator.setAuthenticator(authenticator);
+        validator.initialize();
+        
         action.initialize();
 
         final Event event = action.execute(src);
@@ -166,14 +179,17 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
         ac.getSubcontext(UsernamePasswordContext.class, true);
-        action.setAuthenticator(authenticator);
-        action.setMatchExpression(Pattern.compile("foo.+"));
+        
+        validator.setAuthenticator(authenticator);
+        validator.setMatchExpression(Pattern.compile("foo.+"));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
 
         final Event event = action.execute(src);
-        ActionTestingSupport.assertEvent(event, AuthnEventIds.INVALID_CREDENTIALS);
+        ActionTestingSupport.assertEvent(event, AuthnEventIds.REQUEST_UNSUPPORTED);
     }
 
     @Test public void testBadConfig() throws Exception {
@@ -182,7 +198,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(new Authenticator(new SearchDnResolver(), authHandler));
+        
+        validator.setAuthenticator(new Authenticator(new SearchDnResolver(), authHandler));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -207,8 +226,11 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(new Authenticator(dnResolver,
+        
+        validator.setAuthenticator(new Authenticator(dnResolver,
                 new BindAuthenticationHandler(new DefaultConnectionFactory("ldap://unknown:389"))));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -235,7 +257,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(authenticator);
+        
+        validator.setAuthenticator(authenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -260,7 +285,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(authenticator);
+        
+        validator.setAuthenticator(authenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -277,7 +305,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(authenticator);
+        
+        validator.setAuthenticator(authenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -309,7 +340,9 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
                 response.setAccountState(new PasswordPolicyAccountState(PasswordPolicyControl.Error.PASSWORD_EXPIRED));
             }
         });
-        action.setAuthenticator(errorAuthenticator);
+        validator.setAuthenticator(errorAuthenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -343,7 +376,9 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
                         new PasswordPolicyAccountState(PasswordPolicyControl.Error.CHANGE_AFTER_RESET));
             }
         });
-        action.setAuthenticator(errorAuthenticator);
+        validator.setAuthenticator(errorAuthenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -387,7 +422,9 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
                         new AccountState(new AccountState.DefaultWarning(java.util.Calendar.getInstance(), 10)));
             }
         });
-        action.setAuthenticator(warningAuthenticator);
+        validator.setAuthenticator(warningAuthenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -427,7 +464,9 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
 
-        action.setAuthenticator(authenticator);
+        validator.setAuthenticator(authenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -468,7 +507,9 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(defaultFilterAuthenticator);
+        validator.setAuthenticator(defaultFilterAuthenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -509,7 +550,9 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
 
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
-        action.setAuthenticator(defaultFilterAuthenticator);
+        validator.setAuthenticator(defaultFilterAuthenticator);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -545,8 +588,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
 
-        action.setAuthenticator(authenticator);
-        action.setMatchExpression(Pattern.compile(".+_THE_.+"));
+        validator.setAuthenticator(authenticator);
+        validator.setMatchExpression(Pattern.compile(".+_THE_.+"));
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -582,8 +627,10 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
         AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
         ac.setAttemptedFlow(authenticationFlows.get(0));
 
-        action.setAuthenticator(authenticator);
-        action.setRemoveContextAfterValidation(false);
+        validator.setAuthenticator(authenticator);
+        validator.setRemoveContextAfterValidation(false);
+        validator.initialize();
+        
         action.initialize();
 
         doExtract(prc);
@@ -600,4 +647,5 @@ public class ValidateUsernamePasswordAgainstLDAPTest extends BaseAuthenticationC
         extract.initialize();
         extract.execute(src);
     }
+    
 }
\ No newline at end of file
diff --git a/idp-conf/src/main/resources/system/flows/authn/password-authn-beans.xml b/idp-conf/src/main/resources/system/flows/authn/password-authn-beans.xml
index 183ef27..d0627f5 100644
--- a/idp-conf/src/main/resources/system/flows/authn/password-authn-beans.xml
+++ b/idp-conf/src/main/resources/system/flows/authn/password-authn-beans.xml
@@ -44,22 +44,38 @@
         p:trim-ref="shibboleth.authn.Password.Trim"
         p:transforms-ref="shibboleth.authn.Password.Transforms" />
         
-    <bean id="ValidateUsernamePasswordAgainstJAAS"
-            class="net.shibboleth.idp.authn.impl.ValidateUsernamePasswordAgainstJAAS" scope="prototype"
-            p:savePasswordToCredentialSet="#{getObject('shibboleth.authn.Password.RetainAsPrivateCredential') ?: false}"
-            p:removeContextAfterValidation="#{getObject('shibboleth.authn.Password.RemoveAfterValidation') ?: true}"
+    <bean id="PopulateSubjectCanonicalizationContext"
+        class="net.shibboleth.idp.authn.impl.PopulateSubjectCanonicalizationContext" scope="prototype"
+        p:availableFlows-ref="shibboleth.PostLoginSubjectCanonicalizationFlows" />
+    
+    <!-- New action bean that uses CredentialValidator chains. -->    
+    <bean id="ValidateCredentials"
+        class="net.shibboleth.idp.authn.impl.ValidateCredentials" scope="prototype"
+        p:validators="#{getObject('shibboleth.authn.Password.Validators') ?: getObject('ValidateUsernamePassword')}"
+        p:addDefaultPrincipals="#{getObject('shibboleth.authn.Password.addDefaultPrincipals') ?:
+            (getObject('shibboleth.authn.Password.PrincipalOverride') == null
+                or getObject('shibboleth.authn.Password.PrincipalOverride').isEmpty())}"
+        p:supportedPrincipals="#{getObject('shibboleth.authn.Password.PrincipalOverride')}"
+        p:classifiedMessages-ref="shibboleth.authn.Password.ClassifiedMessageMap"
+        p:resultCachingPredicate="#{getObject('shibboleth.authn.Password.resultCachingPredicate')}"
+        p:lockoutManager="#{getObject('shibboleth.authn.Password.AccountLockoutManager')}" />
+    
+    <!-- New parent bean for defining validators. -->
+    
+    <bean id="shibboleth.CredentialValidator" abstract="true"
+        p:savePasswordToCredentialSet="#{getObject('shibboleth.authn.Password.RetainAsPrivateCredential') ?: false}"
+        p:removeContextAfterValidation="#{getObject('shibboleth.authn.Password.RemoveAfterValidation') ?: true}"
+        p:matchExpression="#{getObject('shibboleth.authn.Password.matchExpression')}" />
+    
+    <!-- Legacy validators defined under V3 action bean names. -->
+    
+    <bean id="ValidateUsernamePasswordAgainstJAAS" parent="shibboleth.CredentialValidator" lazy-init="true"
+            class="net.shibboleth.idp.authn.impl.JAASCredentialValidator"
+            p:id="jaas"
             p:loginConfigStrategy="#{getObject('shibboleth.authn.JAAS.LoginConfigStrategy')}"
             p:loginConfigNames="#{getObject('shibboleth.authn.JAAS.LoginConfigNames')}"
             p:loginConfigurations="#{getObject('shibboleth.authn.JAAS.LoginConfigurations')}"
-            p:loginConfigType="JavaLoginConfig"
-            p:addDefaultPrincipals="#{getObject('shibboleth.authn.Password.addDefaultPrincipals') ?:
-                (getObject('shibboleth.authn.Password.PrincipalOverride') == null
-                    or getObject('shibboleth.authn.Password.PrincipalOverride').isEmpty())}"
-            p:supportedPrincipals="#{getObject('shibboleth.authn.Password.PrincipalOverride')}"
-            p:classifiedMessages-ref="shibboleth.authn.Password.ClassifiedMessageMap"
-            p:resultCachingPredicate="#{getObject('shibboleth.authn.Password.resultCachingPredicate')}"
-            p:matchExpression="#{getObject('shibboleth.authn.Password.matchExpression')}"
-            p:lockoutManager="#{getObject('shibboleth.authn.Password.AccountLockoutManager')}">
+            p:loginConfigType="JavaLoginConfig">
         <property name="loginConfigParameters">
             <bean class="java.security.URIParameter">
                 <constructor-arg ref="shibboleth.authn.JAAS.JAASConfigURI" />
@@ -67,22 +83,13 @@
         </property>
     </bean>
 
-    <bean id="ValidateUsernamePasswordAgainstKerberos"
-        class="net.shibboleth.idp.authn.impl.ValidateUsernamePasswordAgainstKerberos" scope="prototype"
-        p:savePasswordToCredentialSet="#{getObject('shibboleth.authn.Password.RetainAsPrivateCredential') ?: false}"
-        p:removeContextAfterValidation="#{getObject('shibboleth.authn.Password.RemoveAfterValidation') ?: true}"
+    <bean id="ValidateUsernamePasswordAgainstKerberos" parent="shibboleth.CredentialValidator" lazy-init="true"
+        class="net.shibboleth.idp.authn.impl.KerberosCredentialValidator"
+        p:id="krb5"
         p:refreshKrb5Config-ref="shibboleth.authn.Krb5.RefreshConfig"
         p:preserveTicket-ref="shibboleth.authn.Krb5.PreserveTicket"
         p:servicePrincipal="#{getObject('shibboleth.authn.Krb5.ServicePrincipal')}"
-        p:keytabPath="#{getObject('shibboleth.authn.Krb5.Keytab')}"
-        p:addDefaultPrincipals="#{getObject('shibboleth.authn.Password.addDefaultPrincipals') ?:
-            (getObject('shibboleth.authn.Password.PrincipalOverride') == null
-                or getObject('shibboleth.authn.Password.PrincipalOverride').isEmpty())}"
-        p:supportedPrincipals="#{getObject('shibboleth.authn.Password.PrincipalOverride')}"
-        p:classifiedMessages-ref="shibboleth.authn.Password.ClassifiedMessageMap"
-        p:resultCachingPredicate="#{getObject('shibboleth.authn.Password.resultCachingPredicate')}"
-        p:matchExpression="#{getObject('shibboleth.authn.Password.matchExpression')}"
-        p:lockoutManager="#{getObject('shibboleth.authn.Password.AccountLockoutManager')}" />
+        p:keytabPath="#{getObject('shibboleth.authn.Krb5.Keytab')}" />
 
     <!-- Parent beans for custom ldaptive CredentialConfig types. -->
     <bean id="shibboleth.X509ResourceCredentialConfig"
@@ -90,23 +97,10 @@
     <bean id="shibboleth.KeystoreResourceCredentialConfig"
         class="net.shibboleth.idp.authn.impl.KeystoreResourceCredentialConfig" abstract="true" /> 
 
-    <bean id="ValidateUsernamePasswordAgainstLDAP"
-        class="net.shibboleth.idp.authn.impl.ValidateUsernamePasswordAgainstLDAP" scope="prototype"
-        p:savePasswordToCredentialSet="#{getObject('shibboleth.authn.Password.RetainAsPrivateCredential') ?: false}"
-        p:removeContextAfterValidation="#{getObject('shibboleth.authn.Password.RemoveAfterValidation') ?: true}"
+    <bean id="ValidateUsernamePasswordAgainstLDAP" parent="shibboleth.CredentialValidator" lazy-init="true"
+        class="net.shibboleth.idp.authn.impl.LDAPCredentialValidator"
+        p:id="ldap"
         p:authenticator-ref="shibboleth.authn.LDAP.authenticator"
-        p:addDefaultPrincipals="#{getObject('shibboleth.authn.Password.addDefaultPrincipals') ?:
-            (getObject('shibboleth.authn.Password.PrincipalOverride') == null
-                or getObject('shibboleth.authn.Password.PrincipalOverride').isEmpty())}"
-        p:supportedPrincipals="#{getObject('shibboleth.authn.Password.PrincipalOverride')}"
-        p:classifiedMessages-ref="shibboleth.authn.Password.ClassifiedMessageMap"
-        p:resultCachingPredicate="#{getObject('shibboleth.authn.Password.resultCachingPredicate')}"
-        p:matchExpression="#{getObject('shibboleth.authn.Password.matchExpression')}"
-        p:returnAttributes-ref="shibboleth.authn.LDAP.returnAttributes"
-        p:lockoutManager="#{getObject('shibboleth.authn.Password.AccountLockoutManager')}" />
-
-    <bean id="PopulateSubjectCanonicalizationContext"
-        class="net.shibboleth.idp.authn.impl.PopulateSubjectCanonicalizationContext" scope="prototype"
-        p:availableFlows-ref="shibboleth.PostLoginSubjectCanonicalizationFlows" />
+        p:returnAttributes-ref="shibboleth.authn.LDAP.returnAttributes" />
 
 </beans>
diff --git a/idp-conf/src/main/resources/system/flows/authn/password-authn-flow.xml b/idp-conf/src/main/resources/system/flows/authn/password-authn-flow.xml
index 87b3c2d..4c7923f 100644
--- a/idp-conf/src/main/resources/system/flows/authn/password-authn-flow.xml
+++ b/idp-conf/src/main/resources/system/flows/authn/password-authn-flow.xml
@@ -80,7 +80,7 @@
     </action-state>
     
     <action-state id="ValidateUsernamePassword" parent="authn/conditions#ValidateUsernamePassword">
-        <evaluate expression="ValidateUsernamePassword" />
+        <evaluate expression="ValidateCredentials" />
         <evaluate expression="'proceed'" />
 
         <transition on="proceed" to="ContinueSuccessfulAuthentication" />

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list