33

Problem

We want to use Windows Active Directory to authenticate a user into the application. However, we do not want to use Active Directory groups to manage authorization of controllers/views.

As far as I know, there is not an easy way to marry AD and identity based claims.

Goals

  • Authenticate users with local Active Directory
  • Use Identity framework to manage claims

Attempts (Fails)

  • Windows.Owin.Security.ActiveDirectory - Doh. This is for Azure AD. No LDAP support. Could they have called it AzureActiveDirectory instead?
  • Windows Authentication - This is okay with NTLM or Keberos authentication. The problems start with: i) tokens and claims are all managed by AD and I can't figure out how to use identity claims with it.
  • LDAP - But these seems to be forcing me to manually do forms authentication in order to use identity claims? Surely there must be an easier way?

Any help would be more than appreciated. I have been stuck on this problem quite a long time and would appreciate outside input on the matter.

hlyates
  • 1,279
  • 3
  • 22
  • 44
  • 2
    Can you use active directory federated services (ADFS)? If so, it can expose a claims aware authentication point that the windows security model natively understands. After the authentication, you can implement a custom ClaimsAuthenticationManager to fill in the additional custom claims that your application needs. If you can't use ADFS, ThinkTecture has a identity server that is open source. – Patrick Huber Mar 06 '15 at 16:26

6 Answers6

22

Just hit AD with the username and password instead of authenticating against your DB

// POST: /Account/Login
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Login(LoginViewModel model, string returnUrl)
{
    if (ModelState.IsValid)
    {
        var user = await UserManager.FindByNameAsync(model.UserName);
        if (user != null && AuthenticateAD(model.UserName, model.Password))
        {
            await SignInAsync(user, model.RememberMe);
            return RedirectToLocal(returnUrl);
        }
        else
        {
            ModelState.AddModelError("", "Invalid username or password.");
        }
    }
    return View(model);
}

public bool AuthenticateAD(string username, string password)
{
    using(var context = new PrincipalContext(ContextType.Domain, "MYDOMAIN"))
    {
        return context.ValidateCredentials(username, password);
    }
}
KyleMit
  • 30,350
  • 66
  • 462
  • 664
jamesSampica
  • 12,230
  • 3
  • 63
  • 85
  • One thing I am curious about with this approach. Have you been able to use identity based claims in the application? I want to authenticate with AD, but authorize with identity based claims. – hlyates Mar 06 '15 at 06:27
  • `SignInAsync` (from the template) is setting up the identity stuff which includes the claims. All `AuthenticateAD` is doing is checking the user/pass against AD. – jamesSampica Mar 06 '15 at 14:32
  • I assume this requires a login page and thus allows us to hit the Login as a post? – hlyates Mar 06 '15 at 15:18
  • 1
    Yeah, if you generated your project from a template all you would need to do is add the `AuthenticateAD` function and modify the login action. – jamesSampica Mar 06 '15 at 15:23
  • I'm getting an error for SignInAsync. I am using something like SignInAsync(user, isPersistent:false, authenticationMethod: "active directory", cancellationToken: Context.RequetAborted). It gives me a null exception for claims? – hlyates Mar 06 '15 at 16:56
  • 1
    Take out authenticationMethod and cancellationToken. All you need is what is above in my answer. – jamesSampica Mar 06 '15 at 17:09
  • Fair enough. Do you have a link to documentation on what the parameters mean for SignInAsync? I want to understand why I don't need them. Also, PrincipalContext() is mysterious to me. Did you define this yourself or is it a helper method in a library I am obviously unaware of? Thanks for your replies Shoe. – hlyates Mar 06 '15 at 17:15
  • Oh another thing. You code would not work for me unless I stated: SignInManager.SignInAsync. I cannot invoke the method without SignInManager. :-) – hlyates Mar 06 '15 at 17:24
  • 2
    `SignInAsync` used to be a helper method before SignInManager existed. The concept is the same, though. `PrincipalContext` comes from namespace `System.DirectoryServices.AccountManagement`. Add a reference to that in your project to use it. – jamesSampica Mar 06 '15 at 17:35
  • Yeah, what throws me off is I wonder if PrincipalContext exists in ASP.NET5 somewhere? I'm trying to bring System.DirectoryServices.AccountManagement in through the project.json as CTP6 just can't auto resolve that reference by just droppoing. I tried. Joys of beta. :) – hlyates Mar 06 '15 at 17:41
  • 1
    No, it doesn't because it's a namespace dedicated to working with Active Directory, thus not related to ASP.NET – jamesSampica Mar 06 '15 at 17:46
  • Okay, duh. I'm almost there. I just cannot get it to resolve in the VS15 CTP6 project. How did you get this to resolve Shoe? Otherwise, I probably will have to write the LDAP authentication method manually. – hlyates Mar 06 '15 at 17:52
  • 1
    Resolve in what way? Should just be in the .NET framework assemblies by default. Right click "References" -> Add Reference -> search for "directory" – jamesSampica Mar 06 '15 at 18:10
  • @Shoe great solution, however is there an alternative to make this work is developing for both DNX 4.5.1 and DNX Core 5.0? It seems like anything related to ASP.NET Core is Azure AD related only. – Blake Rivell Apr 11 '16 at 14:48
  • 1
    @BlakeRivell Not sure, unfortunately I probably won't get to know until the official release of asp.net 5. When that happens I'll be sure to update this answer. – jamesSampica Apr 11 '16 at 16:48
5

On ASPNET5 (beta6), the idea is to use CookieAuthentication and Identity : you'll need to add in your Startup class :

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddAuthorization();
    services.AddIdentity<MyUser, MyRole>()
        .AddUserStore<MyUserStore<MyUser>>()
        .AddRoleStore<MyRoleStore<MyRole>>()
        .AddUserManager<MyUserManager>()
        .AddDefaultTokenProviders();
}

In the configure section, add:

private void ConfigureAuth(IApplicationBuilder app)
{
    // Use Microsoft.AspNet.Identity & Cookie authentication
    app.UseIdentity();
    app.UseCookieAuthentication(options =>
    {
        options.AutomaticAuthentication = true;
        options.LoginPath = new PathString("/App/Login");
    });
}

Then, you will need to implement:

Microsoft.AspNet.Identity.IUserStore
Microsoft.AspNet.Identity.IRoleStore
Microsoft.AspNet.Identity.IUserClaimsPrincipalFactory

and extend/override:

Microsoft.AspNet.Identity.UserManager
Microsoft.AspNet.Identity.SignInManager

I actually have setup a sample project to show how this can be done. GitHub Link.

I tested on the beta8 and and with some small adaptatons (like Context => HttpContext) it worked too.

KyleMit
  • 30,350
  • 66
  • 462
  • 664
jesblit
  • 51
  • 1
  • 3
  • Hi hlyates, did this post answer your question? – jesblit Nov 06 '15 at 09:54
  • Does this approach still work for RC1? I gave you an upvote. – hlyates Jan 06 '16 at 19:28
  • 1
    Thanks for the approve! Yes this also works with RC1, there are however some naming changes to apply (for example Microsoft.Framework.OptionsModel becomes Microsoft.Extensions.OptionsModel). – jesblit Jan 07 '16 at 10:36
  • Looks like major changes are looming with it ASP.NET Core 1.0 RC2 down the pike? – hlyates Feb 01 '16 at 22:19
  • Yes! It changes too often now and RC was supposed to be stable, but they still make core changes... I'll wait untill RC2 is final before I make changes on my side. – jesblit Feb 22 '16 at 14:18
3

Shoe your solution above pushed me toward a direction that worked for me on MVC6-Beta3 Identityframework7-Beta3 EntityFramework7-Beta3:

// POST: /Account/Login
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }

    //
    // Check for user existance in Identity Framework
    //
    ApplicationUser applicationUser = await _userManager.FindByNameAsync(model.eID);
    if (applicationUser == null)
    {
        ModelState.AddModelError("", "Invalid username");
        return View(model);
    }

    //
    // Authenticate user credentials against Active Directory
    //
    bool isAuthenticated = await Authentication.ValidateCredentialsAsync(
        _applicationSettings.Options.DomainController, 
        _applicationSettings.Options.DomainControllerSslPort, 
        model.eID, model.Password);
    if (isAuthenticated == false)
    {
        ModelState.AddModelError("", "Invalid username or password.");
        return View(model);
    }

    //
    // Signing the user step 1.
    //
    IdentityResult identityResult 
        = await _userManager.CreateAsync(
            applicationUser, 
            cancellationToken: Context.RequestAborted);

    if(identityResult != IdentityResult.Success)
    {
        foreach (IdentityError error in identityResult.Errors)
        {
            ModelState.AddModelError("", error.Description);
        }
        return View(model);
    }

    //
    // Signing the user step 2.
    //
    await _signInManager.SignInAsync(applicationUser,
        isPersistent: false,
        authenticationMethod:null,
        cancellationToken: Context.RequestAborted);

    return RedirectToLocal(returnUrl);
}
KyleMit
  • 30,350
  • 66
  • 462
  • 664
Will
  • 426
  • 3
  • 13
  • 3
    Is `Authentication` an MVC6-only feature? It's not clear where that is supposed to come from and it's kind of a critical piece. – siride Feb 01 '16 at 21:29
  • I believe the "Authentication" is supposed to represent whatever class or domain context that is used to verify the username and password against the domain controller. Such as in the System.DirectoryServices.AccountManagement -> PrincpalContext().ValidateCredentials(username, password) – DtechNet Oct 12 '16 at 21:05
3

You could use ClaimTransformation, I just got it working this afternoon using the article and code below. I am accessing an application with Window Authentication and then adding claims based on permissions stored in a SQL Database. This is a good article that should help you.

https://github.com/aspnet/Security/issues/863

In summary ...

services.AddScoped<IClaimsTransformer, ClaimsTransformer>();

app.UseClaimsTransformation(async (context) =>
{
IClaimsTransformer transformer = context.Context.RequestServices.GetRequiredService<IClaimsTransformer>();
return await transformer.TransformAsync(context);
});

public class ClaimsTransformer : IClaimsTransformer
    {
        private readonly DbContext _context;

        public ClaimsTransformer(DbContext dbContext)
        {
            _context = dbContext;
        }
        public async Task<ClaimsPrincipal> TransformAsync(ClaimsTransformationContext context)
        {

            System.Security.Principal.WindowsIdentity windowsIdentity = null;

            foreach (var i in context.Principal.Identities)
            {
                //windows token
                if (i.GetType() == typeof(System.Security.Principal.WindowsIdentity))
                {
                    windowsIdentity = (System.Security.Principal.WindowsIdentity)i;
                }
            }

            if (windowsIdentity != null)
            {
                //find user in database by username
                var username = windowsIdentity.Name.Remove(0, 6);
                var appUser = _context.User.FirstOrDefault(m => m.Username == username);

                if (appUser != null)
                {

                    ((ClaimsIdentity)context.Principal.Identity).AddClaim(new Claim("Id", Convert.ToString(appUser.Id)));

                    /*//add all claims from security profile
                    foreach (var p in appUser.Id)
                    {
                        ((ClaimsIdentity)context.Principal.Identity).AddClaim(new Claim(p.Permission, "true"));
                    }*/

                }

            }
            return await System.Threading.Tasks.Task.FromResult(context.Principal);
        }
    }
K7Buoy
  • 936
  • 3
  • 13
  • 22
1

Do you know how to implement a custom System.Web.Security.MembershipProvider? You should be able to use this (override ValidateUser) in conjunction with System.DirectoryServices.AccountManagement.PrincipalContext.ValidateCredentials() to authenticate against active directory.

try: var pc = new PrincipalContext(ContextType.Domain, "example.com", "DC=example,DC=com"); pc.ValidateCredentials(username, password);

rybl
  • 1,609
  • 3
  • 14
  • 22
  • Thank you for your reply. If possible, could you please confirm that this suggestion is for ASP.NET5? – hlyates Mar 05 '15 at 22:05
  • No, I didn't realize you were asking about that specifically. I've never tried it on ASP.NET 5 and IDK if Directory Services is in .NET core, but you could use full .NET if need be. – rybl Mar 05 '15 at 22:10
1

I had to design a solution to this problem this way:

1. Any AD authenticated user will be able to access the application.
2. The roles and claims of the users are stored in the Identity database of the application.
3. An admin user will be able to assign roles to users (I have added this functionality to the app as well).

Read on if you want to see my complete solution. The link to the full source code is towards the end of this answer.

Basic design

1. User enters Active Directory credentials (Windows login credentials in this case).
2. The app checks if it's a valid login against AD.
    2.1. If it's not valid, app returns the page with 'Invalid login attempt' error message.
    2.2. If it's valid, go to next step.
3. Check if the user exists in the Identity database.
    3.1. If Not, create this user in our Identity database.
    3.2 If Yes, go to next step.
4. SignIn the user (using AD credentials). This is where we override UserManager.

Note: The user created in step 3.1 has no roles assigned. An admin user (with valid AD username) is created during Db initialization. Adjust the Admin2UserName with your AD username if you want to be the admin user who will assign roles to newly added users. Don't even worry about the password, it can be anything because the actual authentication will happen through AD not through what's in Identity database.

Solution

Step 1: Ensure that you've got Identity setup in your application. As an example, I'm taking a Blazor Server app here. If you don't have Identity setup, follow this guide from Microsoft learn.

Use my project to follow along the guide.

Step 2: Add ADHelper static class to help with Active Directory login. In my example, it's at Areas/Identity/ADHelper.cs and has contents that look like this:

using System.DirectoryServices.AccountManagement;

namespace HMT.Web.Server.Areas.Identity
{
    public static class ADHelper
    {
        public static bool ADLogin(string userName, string password)
        {
            using PrincipalContext principalContext = new(ContextType.Domain);
            bool isValidLogin = principalContext.ValidateCredentials(userName.ToUpper(), password);

            return isValidLogin;
        }
    }
}

Step 3: Override CheckPasswordAsync method in UserManager so you can authenticate users against Active Directory. I have done this in Areas/Identity/ADUserManager.cs, the contents of which look like this:

using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;

namespace HMT.Web.Server.Areas.Identity
{
    public class ADUserManager<TUser> : UserManager<TUser> where TUser : IdentityUser
    {
        public ADUserManager(IUserStore<TUser> store, IOptions<IdentityOptions> optionsAccessor,
            IPasswordHasher<TUser> passwordHasher, IEnumerable<IUserValidator<TUser>> userValidators,
            IEnumerable<IPasswordValidator<TUser>> passwordValidators, ILookupNormalizer keyNormalizer,
            IdentityErrorDescriber errors, IServiceProvider services, ILogger<UserManager<TUser>> logger)
            : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer,
                  errors, services, logger)
        {
        }

        public override Task<bool> CheckPasswordAsync(TUser user, string password)
        {
            var adLoginResult = ADHelper.ADLogin(user.UserName, password);
            return Task.FromResult(adLoginResult);
        }
    }
}

Step 4: Register it in your Program.cs

builder.Services
.AddDefaultIdentity<ApplicationUser>(options =>
{
    options.SignIn.RequireConfirmedAccount = false;
})
.AddRoles<ApplicationRole>()
.AddUserManager<CustomUserManager<ApplicationUser>>()  <----- THIS GUY
.AddEntityFrameworkStores<ApplicationDbContext>();

ApplicationUser, ApplicationRole and ApplicationDbContext look like this:

public class ApplicationUser : IdentityUser
{
}

public class ApplicationRole : IdentityRole
{
}

public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, string>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        // Customize the ASP.NET Identity model and override the defaults if needed.
        // For example, you can rename the ASP.NET Identity table names and more.
        // Add your customizations after calling base.OnModelCreating(builder);
    }
}

Step 5: Update OnPostAsync method in Areas/Identity/Pages/Account/Login.cshtml.cs to implement the authentication flow. The method looks like this:

public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
    returnUrl ??= Url.Content("~/");

    if (ModelState.IsValid)
    {
        // Step 1: Authenticate an user against AD
        // If YES: Go to next step
        // If NO: Terminate the process
        var adLoginResult = ADHelper.ADLogin(Input.UserName, Input.Password);
        if (!adLoginResult)
        {
            ModelState.AddModelError(string.Empty, "Invalid login attempt.");
            return Page();
        }

        // Step 2: Check if the user exists in our Identity Db
        // If YES: Proceed to SignIn the user
        // If NO: Either terminate the process OR create this user in our Identity Db and THEN proceed to SignIn the user
        // I'm going with OR scenario this time
        var user = await _userManager.FindByNameAsync(Input.UserName);
        if (user == null)
        {
            var identityResult = await _userManager.CreateAsync(new ApplicationUser
            {
                UserName = Input.UserName,
            }, Input.Password);

            if (identityResult != IdentityResult.Success)
            {
                ModelState.AddModelError(string.Empty, "The user was authenticated against AD successfully, but failed to be inserted into Application's Identity database.");
                foreach (IdentityError error in identityResult.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }

                return Page();
            }
        }

        // Step 3: SignIn the user using AD credentials
        // 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(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: false);
        if (result.Succeeded)
        {
            _logger.LogInformation("User logged in.");
            return LocalRedirect(returnUrl);
        }
        if (result.RequiresTwoFactor)
        {
            return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
        }
        if (result.IsLockedOut)
        {
            _logger.LogWarning("User account locked out.");
            return RedirectToPage("./Lockout");
        }
        else
        {
            ModelState.AddModelError(string.Empty, "Invalid login attempt.");
            return Page();
        }
    }

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

Basically, we're done here.

Step 6: Now if an admin user wants to assign roles to newly added users, simply go to Manage Users page and assign appropriate roles. Pretty easy, right?

Step 7: If you want to manage roles (add, edit, delete), simply go to manage/roles page.

Conclusion

This setup ensures that users are authenticated using Active Directory and are authorized using roles in the Identity database.

Complete source code

https://github.com/affableashish/blazor-server-auth/tree/feature/AddADAuthentication

Ash K
  • 1,802
  • 17
  • 44