Authenticate ASP.NET Core Identity Users via Active Directory or LDAP Password

Update: I have published an updated 2.0.0-preview00 release that supports ASP.NET Core Identity 2.0 on .NET Standard 2.0 at NuGet.org. I'll publish 2.0.0 without the "preview" tag once I hear back from a couple folks that this resolved their reported issues.

In a project I was recently working on, I needed a way to store and manage user accounts in a stock ASP.NET Core Identity Entity Framework Core based database, but validate user passwords against an existing Active Directory domain. In this situation, I could not leverage Kerberos/Windows Authentication because users were outside the Intranet, nor could I use ADFS or equivalent SSO services as it was beyond the scope of my project to deploy such a solution.

To achieve this, I created a simple UserManager wrapper class that overrides the base CheckPasswordAsync method with one that uses the Novell LDAP library for NETStandard 1.3 to perform an LDAP bind against a directory, and thus perform simple password validation.

I began by creating a UserManager class that inherits from Microsoft.AspNetCore.Identity.UserManager.

/// <summary>
/// Provides a custom user store that overrides password related methods to valid the user's password against LDAP.
/// </summary>
/// <typeparam name="TUser"></typeparam>
public class LdapUserManager<TUser> : Microsoft.AspNetCore.Identity.UserManager<TUser>
where TUser: class

Then I implement CheckPasswordAsync() using an LdapAuthentication class, which is just a loose abstraction around the Novell LDAP library.

/// <summary>
/// Checks the given password agains the configured LDAP server.
/// </summary>
/// <param name="user"></param>
/// <param name="password"></param>
/// <returns></returns>
public override async Task<bool> CheckPasswordAsync(TUser user, string password)
{
    using (var auth = new LdapAuthentication(_ldapOptions))
    {
        string dn;

        // This gives a custom way to extract the DN from the user if it is different from the username.
        if (this.Store is IUserLdapStore<TUser>)
        {
            dn = await((IUserLdapStore<TUser>)this.Store).GetDistinguishedNameAsync(user);
        }
        else
        {
            dn = await this.Store.GetNormalizedUserNameAsync(user, CancellationToken.None);
        }

        if (auth.ValidatePassword(dn, password))
        {
            return true;
        }
    }

    return false;
}

The meat of the LdapAuthentication class is in the ValidatePassword() method.

/// <summary>
/// Gets a value that indicates if the password for the user identified by the given DN is valid.
/// </summary>
/// <param name="distinguishedName"></param>
/// <param name="password"></param>
/// <returns></returns>
public bool ValidatePassword(string distinguishedName, string password)
{
    if (_isDisposed)
    {
        throw new ObjectDisposedException(nameof(LdapConnection));
    }

    if (string.IsNullOrEmpty(_options.Hostname))
    {
        throw new InvalidOperationException("The LDAP Hostname cannot be empty or null.");
    }

    _connection.Connect(_options.Hostname, _options.Port);

    try
    {
        _connection.Bind(distinguishedName, password);
        return true;
    }
    catch (Exception ex)
    {
        System.Diagnostics.Debug.WriteLine(ex.Message);
        return false;
    }
    finally
    {
        _connection.Disconnect();
    }
}

At this point, I just needed some basic configuration and DI code to get things wired up in the Startup.cs of an ASP.NET Core app.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<Justin.AspNetCore.LdapAuthentication.LdapAuthenticationOptions>(this.Configuration.GetSection("ldap"));
    services.AddLdapAuthentication<ApplicationUser>();
    services.AddIdentity<ApplicationUser, IdentityRole>()
        .AddUserManager<Justin.AspNetCore.LdapAuthentication.LdapUserManager<ApplicationUser>>()
        .AddEntityFrameworkStores<ApplicationDbContext>()                
        .AddDefaultTokenProviders();
}

This expects configuration to come from an AppSettings.json section, which looks like this:

"ldap": {
  "Hostname": "dc1.example.com",
  "Port": 389
}

This allows me to keep the user accounts in a database (in this instance, a MySQL database), but eliminates the need for the user to have a separate password. It’s important to note that in my case, users do not need to be able to change, reset, or otherwise manage their user account password through the web interface, as they have a separate existing process in place for that.

I intend on coming back at some point an implementing more of the UserManager methods that *can* be implemented via LDAP, but for now all I needed was to eliminate the need for users to create a separate account password for this app.

The full source code is available on GitHub, or  you can install the NuGet package:

Install-Package -Pre Justin.AspNetCore.LdapAuthentication

Comments (18) -

  • How are you retrieving the user's Distinguished Name from AD ?

    Is there any way to use the Windows User Id for logging in instead of DN ?
    • Robin,

      I think the general answer here is yes, but it would depend on what logging system you are using and where it's getting the user information from. If it's just coming from the http request's user identity, then in theory the logging could use anything available as user claims, which are provided by the User Store being used. This specific LDAP library isn't providing a user store, but rather just providing the password verification piece.

      Thanks,

      Justin
  • Does this work with Windows AD Groups, or just Novell?
    • Joe,

      Yes, it *should* work with AD groups, but I haven't specifically used that with this library.

      Thanks,

      JB
  • Joe
    Does this work without Novell?
    • Joe,

      The library is just a Novell .NET LDAP library, but it will work with any compliant LDAP server, such as Active Directory.
      • So  Novell .NET LDAP is an abstract lib, not specific to Novell. I can query Windows AD with it? Is there docs for this lib I can research?
        • Joe,

          Yes, that is correct. LDAP is a standardized (standard-ish?) that has been implemented by many identity providers, one of which is Microsoft's Active Directory. Domain Controllers in AD support LDAP to query & update the AD directory store.

          The Novell documentation can be found from the link in the second paragraph of this post, which I've put below for you:

          github.com/.../Novell.Directory.Ldap.NETStandard



          Thanks,

          Justin
  • Thanks for the solution.
    That works perfectly.

    But since I migrate my application to .NET core 2.0,  your solution doesn't work anymore...

    Any fix ?
    • Joel, I haven't played around with this with 2.0 yet, but I'll get back to you when I have.
  • Can this pass JWTs to client, and filter data in API route based on AD Group?
    • Joe,

      This library is narrowly focused on the specific use case of validating the user's password against LDAP/Active Directory during the login step so that we don't have to store a password in the database.

      Authorization use cases such as JWTs are not something that this would handle in it's current state. The library could be used to add additional Claims into the user's identity as part of an implementation of IRoleStore/RoleManager. I haven't explored this extensively, sorry.

      Justin
      • Thank you for all your input, this post was amazing and highly beneficial.
        • Wow. Thanks for the input. Glad I could help.
  • Hi,

    I am incorporating your code in my solution for .Net Core 1.0, but i am unable to get through it. I have setup the project with individual user access option and then installed your package and also added settings in Startup.cs.

    Here is my startup. cs "configureservices" method look now:

                services.Configure<Justin.AspNetCore.LdapAuthentication.LdapAuthenticationOptions>(this.Configuration.GetSection("LdapAuth"));
                //services.AddLdapAuthentication<ApplicationUser>();

                services.AddIdentity<ApplicationUser, IdentityRole>()
                    .AddUserManager<Justin.AspNetCore.LdapAuthentication.LdapUserManager<ApplicationUser>>()
                    .AddEntityFrameworkStores<ApplicationDbContext>()
                    .AddDefaultTokenProviders();

    And the login Function in Account Controller like:
    public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
            {
                ViewData["ReturnUrl"] = returnUrl;
                
                if (ModelState.IsValid)
                {
                    // This doesn't count login failures towards account lockout
                    // To enable password failures to trigger account lockout, set lockoutOnFailure: true
                    var result = await _signInManager.PasswordSignInAsync(model.UserName, model.Password, model.RememberMe, lockoutOnFailure: false);
                    if (result.Succeeded)
                    {
                        _logger.LogInformation(1, "User logged in.");
                        return RedirectToLocal(returnUrl);
                    }
                    if (result.IsLockedOut)
                    {
                        _logger.LogWarning(2, "User account locked out.");
                        return View("Lockout");
                    }
                    else
                    {
                        ModelState.AddModelError(string.Empty, "Invalid login attempt.");
                        return View(model);
                    }
                }

                // If we got this far, something failed, redisplay form
                return View(model);
            }

    but all i get is invalid login message. any idea what i am doing wrong.
    • Majid,

      Nothing jumps out at me completely wrong. I it would help if I could see the LdapAuth section of your appsettings.json. Could you kindly open an issue on the github repository for this problem at the URL below:

      github.com/.../issues

      Thanks,

      Justin

Add comment

Loading