Advanced Multi-User Authentication in .NET Core

.Net Core Feb 26, 2024

When building modern multi-tenant or role-diverse systems in .NET Core, supporting different user types that each have difference in authentication behaviors, often goes beyond the standard SignInManager and UserManager. This article explores how to implement advanced authentication by extending ASP.NET Core Identity with custom managers and token providers for distinct user types (e.g Admin, Customers or Vendors).

In this guide, you'll learn how to:

  1. Model separate user types (Admin, Customer, Vendor) using TPT inheritance.
  2. Configure dedicated UserManager<T> and SignInManager<T> instances per user type.
  3. Enforce type-specific authentication rules (e.g MFA for Admins only)
  4. Extend token providers for custom workflows.

The Problem

Out of box, ASP.NET Core Identity is great for generic applications but your app outgrows it as soon as you need:

  1. different login flows for Admins vs Customers.
  2. One use type that uses 2FA or Passwordless login and others don't.
  3. You want separate lockout polices or token lifetimes.

The Goal

Implement a pluggable and extensive identity architecture that:

  • supports multiple user types
  • enables a customer UserManager, SignInManager and TokenProvider per type.
  • Provides separation of concerns and testability

Table-Per-Type inheritance provides a clean approach to model each user type in EF core according to the specifications required for each of them.


What is Table-Per-Type(TPT) in EF Core?

TPT is a database inheritance strategy where:

  • Each class in the hierarchy has its own table.
  • Tables are joined via primary key relationships.
  • Queries are automatically joined when fetching derived types.

Starting from EF Core 5, TPT is fully supported and ideal for DDD-style models.


Step 1: Define User Models with TPT

// Shared base class (maps to AspNetUsers) 
public abstract class BaseUser : IdentityUser  
{
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;  
}  

// Admin-specific fields
[Table(nameof(AppDbContext.AdminUsers))]
public class AdminUser : BaseUser  
{  
    public string Department { get; set; } 
    public bool IsMfaEnforced { get; set; }  
}  

// Customer-specific fields
[Table(nameof(AppDbContext.Customers))]
public class Customer : BaseUser  
{  
    public string ReferralCode { get; set; }  
}


public class AppDbContext : IdentityDbContext<BaseUser>
{
    public DbSet<AdminUser> AdminUsers { get; set; }

    public DbSet<Customer> Customers { get; set; }
}

The types per table are configured using the TableAttribute on the model but it can be alternatively done in the OnModelCreating method in the AppDbContext as seen below:

  1. Calling .ToTable() on each child
protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<AdminUser>().ToTable("AdminUsers");
    builder.Entity<Customer>().ToTable("Customers");
}
  1. Calling .UseTptMappingStrategy() on the BaseClass and the child tables will be automatically generated. By default, EF Core uses TPH (Table-per-Hierarchy), calling .UserTptMappingStrategy() explicitly switches to TPT.
protected override OnModelCreating(ModelBuilder builder)
{
    builder.Entity<BaseUser>().UseTptMappingStrategy();
}
💡
BaseUser model is still mapped to AspNetUsers table by default.

Step 2: Configure Identity Services

Shared Identity Options

void SetupIdentityOptions(IdentityOptions options)  
{  
    options.User.RequireUniqueEmail = true;  
    options.Password.RequiredLength = 6; // Base rule  
    options.Lockout.MaxFailedAccessAttempts = 5;  
}  

Register Identity Stacks

// Base user  
builder.Services.AddIdentity<BaseUser, IdentityRole>(SetupIdentityOptions)  
    .AddEntityFrameworkStores<AppDbContext>()  
    .AddDefaultTokenProviders();  

// Admin User  
builder.Services.AddIdentityCore<AdminUser>(SetupIdentityOptions)  
    .AddRoles<IdentityRole>()  
    .AddEntityFrameworkStores<AppDbContext>()  
    .AddUserManager<AdminUserManager>()  // Custom UserManager  
    .AddSignInManager<AdminSignInManager>()  
    .AddTokenProvider<AdminTokenProvider>(nameof(AdminTokenProvider));  

// Customer  
builder.Services.AddIdentityCore<Customer>(SetupIdentityOptions)  
    .AddEntityFrameworkStores<AppDbContext>()  
    .AddUserManager<CustomerManager>()  
    .AddTokenProvider<CustomerTokenProvider>(nameof(CustomerTokenProvider));  

Step 3: Custom UserManagers

public class AdminUserManager : UserManager<AdminUser>  
{  
    public AdminUserManager(  
        IUserStore<AdminUser> store,  
        IOptions<IdentityOptions> options,  
        IPasswordHasher<AdminUser> passwordHasher,  
        IEnumerable<IUserValidator<AdminUser>> userValidators,  
        IEnumerable<IPasswordValidator<AdminUser>> passwordValidators)  
        : base(store, options, passwordHasher, userValidators, passwordValidators)  
    {  
        // Override defaults  
        Options.Password.RequiredLength = 12;  
        Options.Password.RequireUppercase = true;  
    }  

    public override async Task<IdentityResult> CreateAsync(AdminUser user, string password)  
    {  
        if (!user.Email.EndsWith("@company.com"))  
            return IdentityResult.Failed(new IdentityError { Description = "Admins must use corporate emails." });  

        return await base.CreateAsync(user, password);  
    }  
}  

Step 4: Custom SignInManagers

public class AdminSignInManager : SignInManager<AdminUser>  
{  
    public AdminSignInManager(AdminUserManager userManager, IHttpContextAccessor contextAccessor, IUserClaimsPrincipalFactory<AdminUser> claimsFactory, IOptions<IdentityOptions> optionsAccessor, 
    ILogger<SignInManager<AdminUser>> logger, IAuthenticationSchemeProvider schemes, 
    IUserConfirmation<AdminUser> confirmation) : base(userManager, contextAccessor, claimsFactory, 
    optionsAccessor, logger, schemes, confirmation)
    { }  

    public override async Task<SignInResult> PasswordSignInAsync(  
        string email, string password, bool isPersistent, bool lockoutOnFailure)  
    {  
        var user = await UserManager.FindByEmailAsync(email); 
        
        if (user is { IsMfaEnforced: true } && !await IsMfaEnabled(user))  
            return SignInResult.TwoFactorRequired;  

        if (user is null)
            return SignInResult.Failed;

        return await base.PasswordSignInAsync(user!, password, isPersistent, lockoutOnFailure);  
    }  
}  

Step 5: Token Providers

public class AdminTokenProvider : DataProtectorTokenProvider<AdminUser>  
{  
    public AdminTokenProvider(  
        IDataProtectionProvider dataProtectionProvider,  
        IOptions<AdminTokenProviderOptions> options)  
        : base(dataProtectionProvider, options)  
    { }  
}  

public class AdminTokenProviderOptions : DataProtectionTokenProviderOptions  
{  
    public AdminTokenProviderOptions()  
    {  
        Name = "AdminTokenProvider";  
        TokenLifespan = TimeSpan.FromMinutes(30); // 30m expiry for admins  
    }  
}  

Step 6: Consuming the SignInManager & UserManager

[ApiController]  
[Route("api/admin")]  
public class AdminController : ControllerBase  
{  
    private readonly AdminUserManager _adminManager;  
    private readonly AdminSignInManager _adminSignInManager;  

    public AdminController(  
        AdminUserManager adminManager,  
        AdminSignInManager adminSignInManager)  
    {  
        _adminManager = adminManager;  
        _adminSignInManager = adminSignInManager;  
    }  

    [HttpPost("login")]  
    public async Task<IActionResult> Login(LoginDto dto)  
    {  
        var result = await _adminSignInManager.PasswordSignInAsync(  
            dto.Email, dto.Password, isPersistent: false, lockoutOnFailure: true);  

        return result.Succeeded ? Ok() : Unauthorized();  
    }  
}  

Type-specific password validators can be registered in the Identity stacks using the .AddPasswordValidator.


Performance Considerations

TPT can incur performance costs due to SQL joins when querying base types. Mitigation strategies include:

  • Querying directly on the UserType (e.g AppDbContext.AdminUsers, AppDbContext.Customers e.t.c
  • Adding indexes to primary/foreign keys
  • Avoiding unnecessary inheritance depth

When to Use TPT for User Hierarchies

Use TPT when:

  • Each user type has many distinct properties.
  • You want strong type-safety in business logic

Avoid TPT if:

  • You have shallow hierarchies or just a few fields per type
  • You're performance-sensitive and require flat tables.

Conclusion

Modelling multiple user types in .NET Core using TPT allows you to keep your codebase clean, scalable and expressive. By leveraging EF Core's TPT support, you can operate user concerns elegantly while keeping a consistent authentication and authorization pipeline.

Tags

Views: Loading...