We have a mvc application that is using Active Directory to authenticate our users. We are leveraging System.DirectoryServices
and using the PricipalContext
to authenticate:
_principalContext.ValidateCredentials(userName, pass, ContextOptions.SimpleBind);
However this method only returns a bool and we want to return better messages or even redirect the user to a password reset screen for instances like:
- The user is locked out of their account.
- The users password is expired.
- The user needs to change their password at next login.
So if the user fails to login we call NetValidatePasswordPolicy
to see why the user was not able to log in. This seemed to work well but we realized that this method was only returning NET_API_STATUS.NERR_PasswordMustChange
no matter what the state of the Active Directory user was.
The only example I have found with this same problem comes from a Sublime Speech plugin here. The code I am using is as follows:
var outputPointer = IntPtr.Zero;
var inputArgs = new NET_VALIDATE_PASSWORD_CHANGE_INPUT_ARG { PasswordMatched = false, UserAccountName = username };
inputArgs.ClearPassword = Marshal.StringToBSTR(password);
var inputPointer = IntPtr.Zero;
inputPointer = Marshal.AllocHGlobal(Marshal.SizeOf(inputArgs));
Marshal.StructureToPtr(inputArgs, inputPointer, false);
using (new ComImpersonator(adImpersonatingUserName, adImpersonatingDomainName, adImpersonatingPassword))
{
var status = NetValidatePasswordPolicy(serverName, IntPtr.Zero, NET_VALIDATE_PASSWORD_TYPE.NetValidateAuthentication, inputPointer, ref outputPointer);
if (status == NET_API_STATUS.NERR_Success)
{
var outputArgs = (NET_VALIDATE_OUTPUT_ARG)Marshal.PtrToStructure(outputPointer, typeof(NET_VALIDATE_OUTPUT_ARG));
return outputArgs.ValidationStatus;
}
else
{
//fail
}
}
The code always succeeds so why is the value of outputArgs.ValidationStatus
the same result every time regardless of the state of the Active Directory user?
I will break the answer to this question into three different sections:
- The Current Problem With Your Methodology
- The Issues With Recommended Solutions both Online, and in this Thread
- The Solution
The current problem with your methodology.
NetValidatePasswordPolicy
requires its InputArgs
parameter to take in a pointer to a structure, and the structure you pass in depend on the ValidationType
your're passing in. In this case, you are passing NET_VALIDATE_PASSWORD_TYPE.NetValidateAuthentication
, which requires an InputArgs of NET_VALIDATE_AUTHENTICATION_INPUT_ARG
but you're passing in a pointer to NET_VALIDATE_PASSWORD_CHANGE_INPUT_ARG
.
Furthermore, you are attempting to assign a "currentPassword' type of value to the NET_VALIDATE_PASSWORD_CHANGE_INPUT_ARG
structure.
However, there's a bigger fundamental proble to the use of NetValidatePasswordPolicy
and that is that you are trying to use this function to validate passwords in Active Directory, but this is not what it is used for. NetValidatePasswordPolicy
is used to allow applications to validate against a authentication database provided by the application.
There's more information about NetValidatePasswordPolicy
here.
The issues with recommended solutions both online, and in this thread
Various articles online recommend using the LogonUser
function found in AdvApi32.dll
but this implementation carries its own set of issues:
The first is that LogonUser
validates against a local cache, and that means that you will not get immediate accurate information about the account, unless you use the "Network" mode.
The second is that using LogonUser
on a Web application, in my opinion is a bit hacky, as it is designed for desktop applications running on client machines. However, considering the limitations provided Microsoft if LogonUser
gives desired results, I don't see why it shouldn't be used - barring the caching issues.
Another issue with LogonUser
is that how well it works for your use case depends on how your server is configured, for example: There are some particular permissions that need to be enabled on the domain you're authenticating against that need to be in place for 'Network' logon type to work.
More information about LogonUser
here.
Also, GetLastError()
should not be used, GetLastWin32Error()
should be used instead, as it is not safe to use GetLastError()
.
More information about GetLastWin32Error()
here.
The solution.
In order to get an accurate error code from Active Directory, without any caching issues and straight from directory services, this is what needs to be done: rely on COMException coming back from AD when there's an issue with the account, because ultimately, errors is what you're looking for.
First, here's how you trigger an error from Active Directory on authentication of a current user name and a password:
public LdapBindAuthenticationErrors AuthenticateUser(string domain, string username, string password, string ouString)
{
// The path (ouString) should not include the user in the directory, otherwise this will always return true
DirectoryEntry entry = new DirectoryEntry(ouString, username, password);
try
{
// Bind to the native object, this forces authentication.
var obj = entry.NativeObject;
var search = new DirectorySearcher(entry) { Filter = string.Format("({0}={1})", ActiveDirectoryStringConstants.SamAccountName, username) };
search.PropertiesToLoad.Add("cn");
SearchResult result = search.FindOne();
if (result != null)
{
return LdapBindAuthenticationErrors.OK;
}
}
catch (DirectoryServicesCOMException c)
{
LdapBindAuthenticationErrors ldapBindAuthenticationError = -1;
// These LDAP bind error codes are found in the "data" piece (string) of the extended error message we are evaluating, so we use regex to pull that string
if (Regex.Match(c.ExtendedErrorMessage, @" data (?<ldapBindAuthenticationError>[a-f0-9]+),").Success)
{
string errorHexadecimal = match.Groups["ldapBindAuthenticationError"].Value;
ldapBindAuthenticationError = (LdapBindAuthenticationErrors)Convert.ToInt32(errorHexadecimal , 16);
return ldapBindAuthenticationError;
}
catch (Exception e)
{
throw;
}
}
return LdapBindAuthenticationErrors.ERROR_LOGON_FAILURE;
}
And these are your "LdapBindAuthenticationErrors", you can find more in MSDN, here.
internal enum LdapBindAuthenticationErrors
{
OK = 0
ERROR_INVALID_PASSWORD = 0x56,
ERROR_PASSWORD_RESTRICTION = 0x52D,
ERROR_LOGON_FAILURE = 0x52e,
ERROR_ACCOUNT_RESTRICTION = 0x52f,
ERROR_INVALID_LOGON_HOURS = 0x530,
ERROR_PASSWORD_EXPIRED = 0x532,
ERROR_ACCOUNT_DISABLED = 0x533,
ERROR_ACCOUNT_EXPIRED = 0x701,
ERROR_PASSWORD_MUST_CHANGE = 0x773,
ERROR_ACCOUNT_LOCKED_OUT = 0x775
}
Then you can use the return type of this Enum and do what you need with it in your controller. The important thing to note, is that you're looking for the "data" piece of the string in the "Extended Error Message" of your COMException
because this contains the almighty error code you are hunting for.
Good luck, and I hope this helps. I tested it, and it works great for me.