Blazor - B2C authentication - What is the proper way to persist user data on login? - asp.net-core

I'm building a Blazor app to see how I can persist user data after a B2C AD login.
I want to persist claim data to sql database (ef 6 core) when the user logs in to the app.
I'm trying to capture a Tenant for the user for use in filtering on the app.
I is custom middleware a good way to go with this?
This is a Blazor Server Side app
I have something like this for testing.
public class PersistUserChangesMiddleware
{
private readonly RequestDelegate _next;
public PersistUserChangesMiddleware(RequestDelegate next)
{
_next = next;
}
[Authorize]
public Task Invoke(HttpContext httpContext, MyContext context)
{
try
{
var user = httpContext.User;
var claims = user.Claims;
var tenant = claims?.FirstOrDefault(c => c.Type.Equals("extension_CompanyId", StringComparison.OrdinalIgnoreCase));
if(tenant != null)
{
context.Tenants.Add(new Models.Tenant()
{
TenantName = tenant.Value
});
context.SaveChanges();
}
}
catch (Exception)
{
throw;
}
return _next(httpContext);
}
}
}
I'm not getting the user back from this call in the middleware. Do I need to do it a different way for Blazor? I set [Authorize] but still no user.

I can't see which AuthenticationStateProvider is configured, but it's likely to be ServerAuthenticationStateProvider.
Create a custom AuthenticationStateProvider which is essentially a pass through provider that just grabs the ClaimsPrincipal user and does whatever you want with it. (Let me know if you're using a different provider).
public class MyAuthenticationStateProvider : ServerAuthenticationStateProvider
{
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var authstate = await base.GetAuthenticationStateAsync();
if (authstate.User is not null)
{
ClaimsPrincipal user = authstate.User;
// do stuff with the ClaimsPrincipal
}
return authstate;
}
}
And then register it in Program:
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// sequence is crucial - Must be after AddServerSideBlazor
builder.Services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>();
builder.Services.AddSingleton<WeatherForecastService>();
Test it with a break point on the first line of GetAuthenticationStateAsync.

Related

How can I get id_token in identity server, before redirecting to client?

After the user successfully signed in and before redirecting him to the client site I want to store the id_token in db. Actually the id_token is available in the client side but I don't know how to get it on the login process of identity server.
I would appreciate any help.
You can create a custom ITokenService by inheriting DefaultTokenService and store Id_token after creation.
public class CustomTokenService : DefaultTokenService
{
public CustomTokenService(
IClaimsService claimsProvider,
IReferenceTokenStore referenceTokenStore,
ITokenCreationService creationService,
IHttpContextAccessor contextAccessor,
ISystemClock clock,
ILogger<DefaultTokenService> logger)
: base(claimsProvider, referenceTokenStore, creationService, contextAccessor, clock, logger)
{
}
public override async Task<string> CreateSecurityTokenAsync(Token token)
{
strign jwt = await base.CreateSecurityTokenAsync(token);
// store token
return jwt;
}
}
And also you need to register CustomTokenService
builder.Services.TryAddTransient<ITokenService, CustomTokenService>();
You can use one of the built-in events : TokenIssuedSuccessEvent :
Modify your Startup.cs :
services.AddIdentityServer(options =>
{
options.Events.RaiseSuccessEvents = true;
});
Create your custom IEventSink :
public Task PersistAsync(Event evt)
{
if (evt.Id.Equals(EventIds.TokenIssuedSuccess))
{
var _test = evt as TokenIssuedSuccessEvent;
var tokens = _test.Tokens.ToList();
}
return Task.CompletedTask;
}
Then you can find id token by checking type of each tokens .
At last register the event in Startup.cs:
services.AddScoped<IEventSink, MyEventSink>();

OpenIdConnect access_token size and accessing claims server side

I am trying to wrap my head around several concepts here but I don't want this question to be too broad - basically what we are trying to do is use role claims as permissions to lock down our API but I am finding that the access_token is becoming too big.
We are using OpenIddict and ASP.NET Identity 3 on the server side. We have implemented the default AspNetRoleClaims table to store our claims for each role - using them as permissions.
We lock down our API endpoints using custom policy based claims authorization as shown here:
Custom Policy Based Authorization
The main issue I am finding is that our access_token containing our claims is becoming very large. We are attempting to make the ClaimType and Value to be very small in the database to make the claims footprint smaller. We have a basic CRUD type permission scheme, so for each "module" or screen in our SPA client app, there are 4 permissions. The more modules we add to our application, the more the claims are growing in the access_token and our Authorization Bearer header is becoming very large. I am worried about this becoming not very scalable as the app grows.
So the claims are embedded in the access_token and when I hit my endpoint that is locked down with a custom Policy like this...
[Authorize(Policy="MyModuleCanRead")]
[HttpGet]
public IEnumerable<MyViewModel> Get()
I can then access my ASP.NET Identity User and User.Claims in the AuthorizationHandler.
Sorry in advance if this is an obvious question - but I am wondering - in order to get the Custom Policy Based Authorization to work - does it absolutely require the claims to be in either the id_token or the access_token in order to call the handler?
If I remove the claims from the access_token, then my AuthorizationHandler code does not get hit and I cannot access my endpoint that is locked down with my custom Policy.
I am wondering if it is possible to use a custom claims policy but have the actual code that checks for the Claims inside the Authorization handler, so that the claims are not passed with each HTTP request, but are fetched server side from the Authorization cookie or from the database.
* UPDATE *
Pintpoint's answer using Authorization handlers along with the comment on how to remove additional role claims from the cookie achieved just what I was looking for.
In case this helps anyone else - here is the code to override the UserClaimsPrincipalFactory and prevent the role claims from being written to the cookie. (I had many role claims as permissions and the cookie(s) and request headers were becoming too large)
public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
{
public AppClaimsPrincipalFactory(UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager, IOptions<IdentityOptions> optionsAccessor) : base(userManager, roleManager, optionsAccessor)
{
}
public override async Task<ClaimsPrincipal> CreateAsync(ApplicationUser user)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
var userId = await UserManager.GetUserIdAsync(user);
var userName = await UserManager.GetUserNameAsync(user);
var id = new ClaimsIdentity(Options.Cookies.ApplicationCookieAuthenticationScheme,
Options.ClaimsIdentity.UserNameClaimType,
Options.ClaimsIdentity.RoleClaimType);
id.AddClaim(new Claim(Options.ClaimsIdentity.UserIdClaimType, userId));
id.AddClaim(new Claim(Options.ClaimsIdentity.UserNameClaimType, userName));
if (UserManager.SupportsUserSecurityStamp)
{
id.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType,
await UserManager.GetSecurityStampAsync(user)));
}
// code removed that adds the role claims
if (UserManager.SupportsUserClaim)
{
id.AddClaims(await UserManager.GetClaimsAsync(user));
}
return new ClaimsPrincipal(id);
}
}
I am wondering if it is possible to use a custom claims policy but have the actual code that checks for the Claims inside the Authorization handler, so that the claims are not passed with each HTTP request, but are fetched server side from the Authorization cookie or from the database.
It's definitely possible. Here's how you could do that:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
services.AddAuthorization(options =>
{
options.AddPolicy("Has-Edit-User-Profiles-Permission", builder =>
{
builder.RequirePermission("Edit-User-Profiles");
});
});
}
}
public class PermissionAuthorizationRequirement : IAuthorizationRequirement
{
public PermissionAuthorizationRequirement(string permission)
{
if (string.IsNullOrEmpty(permission))
{
throw new ArgumentException("The permission cannot be null or empty.", nameof(permission));
}
Permission = permission;
}
public string Permission { get; set; }
}
public class PermissionAuthorizationHandler :
AuthorizationHandler<PermissionAuthorizationRequirement>
{
private readonly UserManager<ApplicationUser> _userManager;
public PermissionAuthorizationHandler(UserManager<ApplicationUser> userManager)
{
if (userManager == null)
{
throw new ArgumentNullException(nameof(userManager));
}
_userManager = userManager;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionAuthorizationRequirement requirement)
{
if (context.User == null)
{
return;
}
var user = await _userManager.GetUserAsync(context.User);
if (user == null)
{
return;
}
// Use whatever API you need to ensure the user has the requested permission.
if (await _userManager.IsInRoleAsync(user, requirement.Permission))
{
context.Succeed(requirement);
}
}
}
public static class PermissionAuthorizationExtensions
{
public static AuthorizationPolicyBuilder RequirePermission(
this AuthorizationPolicyBuilder builder, string permission)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (string.IsNullOrEmpty(permission))
{
throw new ArgumentException("The permission cannot be null or empty.", nameof(permission));
}
return builder.AddRequirements(new PermissionAuthorizationRequirement(permission));
}
}

ASP.NET Core Windows Authentication and Application Roles

I'm trying to create a fairly simple intranet application that will use Active Directory for authentication, and will use the AspNetRoles table to check if the user is in a certain application role. This app is just an in-house lottery where some users can create events/contests that other users can then submit an entry to the contest. I'm thinking of starting out with 2 basic roles:
Administrator - Can perform CRUD operations on "Event" or
"Contest" entities
Contestant - Can perform GET operations on
"Contest" entities, and can create new "Entry" entities.
Here's where I'm stuck: I've got Windows Authentication working in the sense that from a controller, I can do a User.Identity.Name and see my domain login name. Furthermore, I can verify that an account belongs to a domain group by doing User.IsInRole("Domain Users"). If I want to avoid creating new AD groups for each role in my application (let's say design changes down the road require additional roles), how can I use Authorization on controllers to check against Application Roles?
Here's an example controller I want to use:
[Route("api/[controller]")]
[Authorize(Roles = "Contestant")]
public class EventTypesController : Controller
{
private IRaffleRepository _repository;
private ILogger<EventTypesController> _logger;
public EventTypesController(IRaffleRepository repository, ILogger<EventTypesController> logger)
{
_repository = repository;
_logger = logger;
}
[HttpGet("")]
public IActionResult Get()
{
try
{
var results = _repository.GetAllEventTypes();
return Ok(Mapper.Map<IEnumerable<EventTypeViewModel>>(results));
}
catch (Exception ex)
{
_logger.LogError($"Failed to get all event types: {ex}");
return BadRequest("Error occurred");
}
}
}
In my Startup.cs, in ConfigureServices, I'm wiring up Identity as follows:
services.AddIdentity<RaffleUser, ApplicationRole>()
.AddEntityFrameworkStores<RaffleContext>();
My RaffleUser class is really just the default implementation of IdentityUser:
public class RaffleUser : IdentityUser
{
}
My ApplicationRole class is also just the default implementation of IdentityRole. I also tried seeding some data in a seed class:
if (!await _roleManager.RoleExistsAsync("Administrator"))
{
var adminRole = new ApplicationRole()
{
Name = "Administrator"
};
await _roleManager.CreateAsync(adminRole);
await _context.SaveChangesAsync();
}
if (await _userManager.FindByNameAsync("jmoor") == null)
{
using (var context = new PrincipalContext(ContextType.Domain))
{
var principal = UserPrincipal.FindByIdentity(context, "DOMAIN\\jmoor");
if (principal != null)
{
var user = new RaffleUser()
{
Email = principal.EmailAddress,
UserName = principal.SamAccountName
};
await _userManager.CreateAsync(user);
await _context.SaveChangesAsync();
var adminRole = await _roleManager.FindByNameAsync("Administrator");
if (adminRole != null)
{
await _userManager.AddToRoleAsync(user, adminRole.Name);
await _context.SaveChangesAsync();
}
}
}
}
The data makes it to the tables, but it just seems like at the controller level, I need to convert the authenticated user to an IdentityUser. Do I need some middleware class to do this for me? Would that be the best way to make authorization reusable on all controllers?
First, I ended up creating a custom ClaimsTransformer that returns a ClaimsPrincipal populated with UserClaims and RoleClaims (after refactoring my app, I decided to go with policy-based authorization, and the access claim can be added at either the role or user level):
public async Task<ClaimsPrincipal> TransformAsync(ClaimsTransformationContext context)
{
var identity = (ClaimsIdentity)context.Principal.Identity;
var userName = identity.Name;
if (userName != null)
{
var user = await _userManager.FindByLoginAsync("ActiveDirectory", userName);
if (user != null)
{
identity.AddClaims(await _userManager.GetClaimsAsync(user));
var roles = await _userManager.GetRolesAsync(user);
identity.AddClaims(await GetRoleClaims(roles));
}
}
return context.Principal;
}
private async Task<List<Claim>> GetRoleClaims(IList<string> roles)
{
List<Claim> allRoleClaims = new List<Claim>();
foreach (var role in roles)
{
var rmRole = await _roleManager.FindByNameAsync(role);
var claimsToAdd = await _roleManager.GetClaimsAsync(rmRole);
allRoleClaims.AddRange(claimsToAdd);
}
return allRoleClaims;
}
I wired that up in the Startup.cs:
services.AddScoped<IClaimsTransformer, Services.ClaimsTransformer>();
I also went with Policy-based authorization:
services.AddAuthorization(options =>
{
options.AddPolicy("Administrator", policy => policy.RequireClaim("AccessLevel", "Administrator"));
options.AddPolicy("Project Manager", policy => policy.RequireClaim("AccessLevel", "Project Manager"));
});
So, users or roles can have a claim set with a name of "AccessLevel" and a value specified. To finish everything off, I also created a custom UserManager that just populates the User object with additional details from ActiveDirectory during a CreateAsync.
You need to add a DefaultChallangeScheme to use Windows authentication. This is how i do, but if someone has a better solution i am all ears :)
I use the following setup in my current application.
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<SecurityDbContext>()
.AddDefaultTokenProviders();
services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = IISDefaults.AuthenticationScheme;
});
Then i put in my application claims in a transformer.
services.AddTransient<IClaimsTransformation, ClaimsTransformer>();
I hope this will get you in the right direction.

Windows authentication/authorization

I am working on a website where I need to authorize the user through a service. I have managed to get windows authentication working if I use the AuthorizeAttribute (User.Identities will be set). My plan is to create a custom middleware that sets the roles/claims for the user but context.User is not set in the middleware. User.Identities will also not be set in the controllers where I don't add the AuthorizeAttribute.
My goal is to write a middleware that gets the windows username and calls a service with the username to get the roles the user has access to and then set the roles or claims for the user.
public class RoleMiddleware
{
private readonly RequestDelegate _next;
public RoleMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
if (!rolesSet)
{
var result = _service.GetRoles(context.User.Identity.Name);
//set roles
//set claims
}
await _next.Invoke(context);
}
}
Would a middleware be the correct place to do this and what do I need to do to get access to the username in the same way as I do when I use the AuthorizeAttribute in a controller?
In my opinion that's not the right way to do it. ASP.NET Identity provide rich set of classes which you can override and extend to fit your requirements.
If you want to inject roles bases on some custom service then you should override RoleStore (and maybe RoleManager too) and inject there your custom roles.
It will be also worth to take a look here: Using Role Claims in ASP.NET Identity Core
I solved it by using requirements
public class CustomFunctionRequirement : IAuthorizationRequirement
{
public CustomFunctionRequirement(string function)
{
Function = function;
}
public string Function { get; }
}
The handler
public class CustomFunctionHandler : AuthorizationHandler<CustomFunctionRequirement>
{
private readonly Service _service;
public CustomFunctionHandler(Service service)
{
_service = service;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomFunctionRequirement requirement)
{
var functions = _service.GetFunctions(context.User.Identity.Name);
if (functions.Any(x => x == requirement.Function))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Setup in ConfigureServices in Startup
services.AddMvc(
config =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
services.AddAuthorization(
options =>
{
options.AddPolicy("User", policy => policy.Requirements.Add(new CustomRequirement("User")));
});
I can now in my controller specify the requirement by adding the authorize attribute [Authorize(Policy = "User")].

How to use Windows Active Directory Authentication and Identity Based Claims?

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.
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);
}
}
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.
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);
}
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);
}
}
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);
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