Search code examples
c#asp.net-core-mvcasp.net-core-identity

How can I have multiple Identity user types when using ASP.NET Core Identity and Entity Framework Core?


I have two types of users within my application, Customers and Professionals. I'm using ASP.NET Identity to manage my users. Currently I have only a single user of type ApplicationUser and use flags to differentiate between Customers and Professionals.

However this approach seems rather messy to me. So I'm trying to to store identity related information on the Identity Framework default table, Customer related information in a Customers table and Professional related information on a Professionals table.

These are my new user entities on my domain:

public class Account : IdentityUser<Guid>
{
    public string FullName { get; set; } = string.Empty;
    public string ImageUrl { get; set; } = string.Empty;
    public bool HasAcceptedTermsOfUse { get; set; } = default;
}

public class Customer : Account
{
    // Customer specific properties
}

public class Professional : Account
{
    // Professional specific properties
}

This is my modified DbContext to support both Customers and Professionals:

public class XenDbContext: IdentityDbContext<Account, IdentityRole<Guid>, Guid>
{
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Professional> Professionals { get; set; }
    // Other DbSets

    public XenDbContext(DbContextOptions<XenDbContext> options) 
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        builder.Entity<Account>(entity => { entity.ToTable("Accounts"); });
        builder.Entity<Customer>(entity => { entity.ToTable("Customers"); });
        builder.Entity<Professional>(entity => { entity.ToTable("Professionals"); });
        builder.Entity<IdentityRole<Guid>>(entity => { entity.ToTable("Roles"); });
        builder.Entity<IdentityUserRole<Guid>>(entity => { entity.ToTable("AccountRoles"); });
        builder.Entity<IdentityUserClaim<Guid>>(entity => { entity.ToTable("AccountClaims"); });
        builder.Entity<IdentityUserLogin<Guid>>(entity => { entity.ToTable("AccountLogins"); });
        builder.Entity<IdentityUserToken<Guid>>(entity => { entity.ToTable("AccountTokens"); });
        builder.Entity<IdentityRoleClaim<Guid>>(entity => { entity.ToTable("RoleClaims"); });
    }
}

I'm able to run Add-Migration and Update-Database with this structure. However when I run my API I get this error:

System.InvalidOperationException
HResult=0x80131509
Message=Scheme already exists: Identity.Application
Source=Microsoft.AspNetCore.Authentication.Abstractions

StackTrace:
at Microsoft.AspNetCore.Authentication.AuthenticationOptions.AddScheme(String name, Action1 configureBuilder) at Microsoft.AspNetCore.Authentication.AuthenticationBuilder.<>c__DisplayClass4_02.b__0(AuthenticationOptions o)
at Microsoft.Extensions.Options.ConfigureNamedOptions1.Configure(String name, TOptions options) at Microsoft.Extensions.Options.OptionsFactory1.Create(String name) at Microsoft.Extensions.Options.UnnamedOptionsManager1.get_Value() at Microsoft.AspNetCore.Authentication.AuthenticationSchemeProvider..ctor(IOptions1 options, IDictionary2 schemes) at Microsoft.AspNetCore.Authentication.AuthenticationSchemeProvider..ctor(IOptions1 options) at System.RuntimeMethodHandle.InvokeMethod(Object target, Span1& arguments, Signature sig, Boolean constructor, Boolean wrapExceptions) at System.Reflection.RuntimeConstructorInfo.Invoke(BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitRootCache(ServiceCallSite callSite, RuntimeResolverContext context) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSite(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope) at Microsoft.Extensions.DependencyInjection.ServiceProvider.CreateServiceAccessor(Type serviceType) at System.Collections.Concurrent.ConcurrentDictionary2.GetOrAdd(TKey key, Func`2 valueFactory) at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope) at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(Type serviceType) at Microsoft.Extensions.Internal.ActivatorUtilities.ConstructorMatcher.CreateInstance(IServiceProvider provider) at Microsoft.Extensions.Internal.ActivatorUtilities.CreateInstance(IServiceProvider provider, Type instanceType, Object[] parameters) at Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.<>c__DisplayClass5_0.b__0(RequestDelegate next) at Microsoft.AspNetCore.Builder.ApplicationBuilder.Build() at Microsoft.AspNetCore.Builder.WebApplicationBuilder.b__27_0(RequestDelegate next) at Microsoft.AspNetCore.Builder.ApplicationBuilder.Build() at Microsoft.AspNetCore.Hosting.GenericWebHostService.d__37.MoveNext() at Microsoft.Extensions.Hosting.Internal.Host.d__12.MoveNext() at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.d__4.MoveNext() at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.d__4.MoveNext() at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host) at Microsoft.AspNetCore.Builder.WebApplication.Run(String url) at Xen.Presentation.API.Program.Main(String[] args) in C:\Users\Arthur\Development\Xen\src\backend\Presentation\Xen.Presentation.API\Program.cs:line 53

I don't understand a lot about Identity Framework but it seems like the problem arises when I have multiple AddIdentity calls.

This is my PersistenceServiceRegistration in my persistence layer:

public static class PersistenceServiceRegistration
{
    public static void AddPersistenceServices(this IServiceCollection services, IConfiguration configuration)
    {
        var connectionString = configuration.GetConnectionString("XenDbLocalConnectionString");
        
        services.AddDbContext<XenDbContext>(options => options.UseSqlServer(connectionString));

        services.AddIdentity<Account, IdentityRole<Guid>>(options =>
        {
            options.Password.RequireDigit = false;
        }).AddEntityFrameworkStores<XenDbContext>().AddDefaultTokenProviders();

        services.AddIdentity<Customer, IdentityRole<Guid>>(options =>
        {
            options.Password.RequireDigit = false;
        }).AddEntityFrameworkStores<XenDbContext>().AddDefaultTokenProviders();

        services.AddIdentity<Professional, IdentityRole<Guid>>(options =>
        {
            options.Password.RequireDigit = false;
        }).AddEntityFrameworkStores<XenDbContext>().AddDefaultTokenProviders();

        // Register repositories
    }
}

In addition to Professionals and Customers, I have AddIdentity for Account because I want to be able to generate tokens for both Customers and Professionals via Account. This is my full PersistenceServiceRegistration.cs with the code I removed for brevity.

This is the Program.cs on my API:

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddApplicationServices();

        builder.Services.AddPersistenceServices(builder.Configuration);

        builder.Services.AddInfrastructureServices(builder.Configuration);

        builder.Services.AddHttpContextAccessor();

        builder.Services.AddControllers().AddJsonOptions(options =>
        {
            options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
        });

        builder.Services.AddCors(options =>
        {
            options.AddPolicy("Open", builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
        });

        builder.Services.AddEndpointsApiExplorer();

        builder.Services.AddSwaggerGen();

        var app = builder.Build();
     
        app.UseSwagger();
        app.UseSwaggerUI();

        app.UseHttpsRedirection();

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseMiddleware<ExceptionHandlerMiddleware>();

        app.MapControllers();

        app.Run();
    }
}

One of the references I've used to implement this was this post. I have also tried this approach to work around the issue. I also gave this a shot, but I'm not sure I understand it.

Is it possible to do what I'm trying? And if so, how can I achieve it?

Edit:

I managed to make it work by commenting out the app.UseAuthorization() method in Program.cs. However that's not a solution, since I need authentication and authorization.

Here is a sample with the sample program on GitHub, if you run it you will run into the same problem.


Solution

  • OK. After a lot failure I finally managed to get it working. If you're having the same issue, this is how I managed to do it:

    Simply replace AddIdentity with AddIdentityCore. From what I was able to gather reading the source code they are quite similar, but AddIdentity by default register some authentication schemes, while AddIdentityCore doesn't. Mainly, AddIdentity already calls AddAuthentication() for you. So when you have three calls to AddIdentity you are calling AddAuthentication three times. So while AddIdentity can be useful most of the times, in scenarios like this you need to use AddIdentityCore to avoid creating multiple auth schemes with the same name.

            services.AddDbContext<XenDbContext>(options => options.UseSqlServer(connectionString));
    
            services.AddIdentityCore<Account>(options =>
            {
                options.Password.RequireDigit = false;
            })
            .AddRoles<IdentityRole<Guid>>()
            .AddEntityFrameworkStores<XenDbContext>()
            .AddDefaultTokenProviders();
    
            services.AddIdentityCore<Customer>(options =>
            {
                options.Password.RequireDigit = false;
            })
            .AddRoles<IdentityRole<Guid>>()
            .AddEntityFrameworkStores<XenDbContext>()
            .AddDefaultTokenProviders();
    
            services.AddIdentityCore<Professional>(options =>
            {
                options.Password.RequireDigit = false;
            })
            .AddRoles<IdentityRole<Guid>>()
            .AddEntityFrameworkStores<XenDbContext>()
            .AddDefaultTokenProviders();