Implementing User Management with ASP.NET Core Identity and custom claims

The article shows how to implement user management for an ASP.NET Core application using ASP.NET Core Identity. The application uses custom claims, which need to be added to the user identity after a successful login, and then an ASP.NET Core policy is used to authorize the identity.

Code: https://github.com/damienbod/AspNetCoreAngularSignalRSecurity

History

2023-01-07 Updated .NET 7, Angular 15, Duende Identity Server

2021-01-25 Updated Angular 11.1.0 .NET 5, ngrx implementation

2020-03-21 updated packages, fixed Admin UI STS

2019-08-18 Updated ASP.NET Core 3.0, Angular 8.2.2

Setting up the Project

The demo application is implemented using ASP.NET Core MVC and uses the Duende Identity Server and Duende.IdentityServer.AspNetIdentity NuGet packages.

ASP.NET Core Identity is then added in the hosting extension class using builder.Services property. SQLite is used as a database. A scoped service for the IUserClaimsPrincipalFactory is added so that the additional claims can be added to the Context.User.Identity scoped object.

An IAuthorizationHandler service is added, so that the IsAdminHandler can be used for the IsAdmin policy. This policy can then be used to check if the identity has the custom claims which was added to the identity in the AdditionalUserClaimsPrincipalFactory implementation.

public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
	builder.Services.AddRazorPages();

	builder.Services.AddDbContext<ApplicationDbContext>(options =>
		options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

	builder.Services.AddCors(options =>
	{
		options.AddPolicy("AllowAllOrigins",
			builder =>
			{
				builder
					.AllowCredentials()
					.WithOrigins(
						"https://localhost:44311", 
						"https://localhost:44390", 
						"https://localhost:44395",
						"https://localhost:5001")
					.SetIsOriginAllowedToAllowWildcardSubdomains()
					.AllowAnyHeader()
					.AllowAnyMethod();
			});
	});

	builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
	  .AddEntityFrameworkStores<ApplicationDbContext>()
	  .AddDefaultTokenProviders()
	  .AddDefaultUI()
	  .AddTokenProvider<Fido2UserTwoFactorTokenProvider>("FIDO2");

	builder.Services.Configure<Fido2Configuration>(builder.Configuration.GetSection("fido2"));
	builder.Services.AddScoped<Fido2Store>();

	builder.Services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>,
	   AdditionalUserClaimsPrincipalFactory>();

	// Adds a default in-memory implementation of IDistributedCache.
	builder.Services.AddDistributedMemoryCache();
	builder.Services.AddSession(options =>
	{
		options.IdleTimeout = TimeSpan.FromMinutes(2);
		options.Cookie.HttpOnly = true;
		options.Cookie.SameSite = SameSiteMode.None;
		options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
	});

	builder.Services.AddIdentityServer(options =>
	{
		options.Events.RaiseErrorEvents = true;
		options.Events.RaiseInformationEvents = true;
		options.Events.RaiseFailureEvents = true;
		options.Events.RaiseSuccessEvents = true;
		options.UserInteraction.LoginUrl = "/Identity/Account/Login";
		options.UserInteraction.LogoutUrl = "/Identity/Account/Logout";

		// see https://docs.duendesoftware.com/identityserver/v6/fundamentals/resources/
		options.EmitStaticAudienceClaim = true;
	})
	.AddInMemoryIdentityResources(Config.IdentityResources)
	.AddInMemoryApiScopes(Config.ApiScopes)
	.AddInMemoryClients(Config.Clients)
	.AddInMemoryApiResources(Config.ApiResources)
	.AddAspNetIdentity<ApplicationUser>()
	.AddProfileService<IdentityWithAdditionalClaimsProfileService>();

	builder.Services.AddAuthentication();

	builder.Services.AddSingleton<IAuthorizationHandler, IsAdminHandler>();
	builder.Services.AddAuthorization(options =>
	{
		options.AddPolicy("IsAdmin", policyIsAdminRequirement =>
		{
			policyIsAdminRequirement.Requirements.Add(new IsAdminRequirement());
		});
	});

	return builder.Build();
}

The application uses IdentityServer. The UseIdentityServer extension is used instead of the UseAuthentication method to use the authentication.

public static WebApplication ConfigurePipeline(this WebApplication app, IWebHostEnvironment env)
{
	app.UseSecurityHeaders(
		SecurityHeadersDefinitions.GetHeaderPolicyCollection(env.IsDevelopment()));

	app.UseSerilogRequestLogging();

	if (app.Environment.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}

	app.UseCors("AllowAllOrigins");

	app.UseStaticFiles();
	app.UseRouting();

	app.UseIdentityServer();
	app.UseAuthorization();
	
	app.MapRazorPages().RequireAuthorization();

	app.MapControllers();

	app.UseSession();

	return app;
}

The ApplicationUser class implements the IdentityUser class. Additional database fields can be added here, which will then be used to create the claims for the logged in user.

public class ApplicationUser : IdentityUser
{
    public bool IsAdmin { get; set; }
    public string DataEventRecordsRole { get; set; }
    public string SecuredFilesRole { get; set; }
}

The AdditionalUserClaimsPrincipalFactory class implements the UserClaimsPrincipalFactory class, and can be used to add the additional claims to the user object in the HTTP context. This was added as a scoped service in the Startup class. The ApplicationUser is then used, so that the custom claims can be added to the identity.

using IdentityModel;
using IdentityServerHost.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Security.Claims;

namespace IdentityServerAspNetIdentity;

public class AdditionalUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
{
    public AdditionalUserClaimsPrincipalFactory(
        UserManager<ApplicationUser> userManager,
        RoleManager<IdentityRole> roleManager,
        IOptions<IdentityOptions> optionsAccessor)
        : base(userManager, roleManager, optionsAccessor)
    {
    }

    public async override Task<ClaimsPrincipal> CreateAsync(ApplicationUser user)
    {
        var principal = await base.CreateAsync(user);
        var identity = (ClaimsIdentity)principal.Identity;

        var claims = new List<Claim>
        {
            new Claim(JwtClaimTypes.Role, "dataEventRecords"),
            new Claim(JwtClaimTypes.Role, "dataEventRecords.user")
        };

        if (user.DataEventRecordsRole == "dataEventRecords.admin")
        {
            claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.admin"));
        }

        if (user.IsAdmin)
        {
            claims.Add(new Claim(JwtClaimTypes.Role, "admin"));
        }
        else
        {
            claims.Add(new Claim(JwtClaimTypes.Role, "user"));
        }

        identity.AddClaims(claims);
        return principal;
    }
}

Now the policy IsAdmin can check for this. First a requirement is defined. This is done by implementing the IAuthorizationRequirement interface.

public class IsAdminRequirement : IAuthorizationRequirement{}

The IsAdminHandler AuthorizationHandler uses the IsAdminRequirement requirement. If the user has the role claim with value admin, then the handler will succeed.

public class IsAdminHandler : AuthorizationHandler<IsAdminRequirement>
{
    protected override Task HandleRequirementAsync(
		AuthorizationHandlerContext context, 
		IsAdminRequirement requirement)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));
        if (requirement == null)
            throw new ArgumentNullException(nameof(requirement));

        var adminClaim = context.User.Claims
			.FirstOrDefault(t => t.Value == "admin" && t.Type == "role");
        if (adminClaim != null)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

The Admin Pges adds a way to do the CRUD operations for the Identity users. The Admin Razor Pages uses the Authorize attribute with the policy IsAdmin to authorize. The AuthenticationSchemes needs to be set to “Identity.Application”, because Identity is being used. Now admins can create, or edit Identity users.

using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using IdentityServerAspNetIdentity.Data;
using IdentityServerAspNetIdentity.Models;
using Microsoft.AspNetCore.Authorization;

namespace Sts.Pages.Admin;

[Authorize(AuthenticationSchemes = "Identity.Application", Policy = "IsAdmin")]
public class IndexModel : PageModel
{
    private readonly ApplicationDbContext _context;

    public IndexModel(ApplicationDbContext context)
    {
        _context = context;
    }

    public IList<AdminViewModel> AdminViewModel { get;set; }

    public async Task OnGetAsync()
    {
        AdminViewModel = await _context.Users.Select(user =>
        new AdminViewModel
        {
            Email = user.Email,
            IsAdmin = user.IsAdmin,
            DataEventRecordsRole = user.DataEventRecordsRole,
            SecuredFilesRole = user.SecuredFilesRole
        }).ToListAsync();
    }
}

Or the Razor page to edit would look like this:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using IdentityServerAspNetIdentity.Models;
using IdentityServerHost.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authorization;

namespace Sts.Pages.Admin;

[Authorize(AuthenticationSchemes = "Identity.Application", Policy = "IsAdmin")]
public class EditModel : PageModel
{
    private readonly UserManager<ApplicationUser> _userManager;

    public EditModel(UserManager<ApplicationUser> userManager)
    {
        _userManager = userManager;
    }

    [BindProperty]
    public AdminViewModel AdminViewModel { get; set; }

    public async Task<IActionResult> OnGetAsync(string? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var user = await _userManager.FindByEmailAsync(id);
        if (user == null)
        {
            return NotFound();
        }

        AdminViewModel = new AdminViewModel
        {
            Email = user.Email,
            IsAdmin = user.IsAdmin,
            DataEventRecordsRole = user.DataEventRecordsRole,
            SecuredFilesRole = user.SecuredFilesRole
        };

        return Page();
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        var user = await _userManager.FindByEmailAsync(AdminViewModel.Email);
        if (user == null)
        {
            return NotFound();
        }

        user.IsAdmin = AdminViewModel.IsAdmin;
        user.DataEventRecordsRole = AdminViewModel.DataEventRecordsRole;
        user.SecuredFilesRole = AdminViewModel.SecuredFilesRole;

        await _userManager.UpdateAsync(user);

        return RedirectToPage("./Index");
    }
}

Running the application

When the application is started, the ADMIN menu can be clicked, and the users can be managed by administrators.

Links

https://learn.microsoft.com/en-us/aspnet/core/security/authentication/claims

https://duendesoftware.com/products/identityserver

https://adrientorris.github.io/aspnet-core/identity/extend-user-model.html

https://benfoster.io/blog/customising-claims-transformation-in-aspnet-core-identity

6 comments

  1. Fiyaz Bin Hasan · · Reply

    Nicely written. What I was looking for 🙂

  2. […] Implementing User Management with ASP.NET Core Identity and custom claims (Damien Bowden) […]

  3. Awesome Article…!!!! Thanks for sharing such a wonderful information with us. Glad to know the Implementing User Management with ASP.NET Core Identity and custom claims.

  4. Why isn’t the claim of ‘user’ always added, and then additionally add a claim of ‘admin’ if appropriate? Should anyone allowed into the system at all have the claim of being a user? Then once a user, their claim to administrate certain aspects be granted as well?

  5. Randy Gamage · · Reply

    I don’t think it’s a good idea to add a bool “isAdmin” field to your user model. That adds a duplicate location for storing this information, and could easily get out of sync with the actual role/claim of admin.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: