【.NET 5】【ASP.NET Core Identity】SignIn with custom user
Tags: dotnet, csharp, aspnetcore
Published: 2021-02-03 15:18:07

## Intro
In this time, I try signing in with custom user.
Because the default ASP.NET Core Identity user has so much properties and they are too much for me.

* Id
* UserName
* Email
* PasswordHash
* EmailConfirmed
* NormalizedUserName
* NormalizedEmail          
* LockoutEnabled
* AccessFailedCount
* PhoneNumber
* ConcurrencyStamp
* SecurityStamp
* LockoutEnd
* TwoFactorEnabled
* PhoneNumberConfirmed

I will use first four one.
To do this, I must do some things.

### Environments
* NLog.Web.AspNetCore ver.4.10.0
* Npgsql.EntityFrameworkCore.PostgreSQL ver.5.0.2
* Microsoft.EntityFrameworkCore ver.5.0.2
* Microsoft.EntityFrameworkCore.Design ver.5.0.2
* Newtonsoft.Json ver.12.0.3
* Microsoft.AspNetCore.Mvc.NewtonsoftJson ver.5.0.2
* Microsoft.AspNetCore.Identity.EntityFrameworkCore

### Samples
* [ApproveWorkflowSample](https://github.com/masanori840816/ApproveWorkflowSample)

## Create custom user
To create custom user, I inherit "IdentityUser".
And I added "Organization" and "LastUpdateDate".

#### ApplicationUser.cs
```cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.AspNetCore.Identity;

namespace ApprovementWorkflowSample.Applications
{
    // To use int as ID type, I inherited "IdentityUser<int>".
    public class ApplicationUser: IdentityUser<int>
    {
        [Key]
        [Column("id")]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public override int Id { get; set; }
        [Required]
        [Column("user_name")]
        public override string UserName { get; set; } = "";
        [Column("organization")]
        public string? Organization { get; set; }
        [Required]
        [Column("mail")]
        public override string Email { get; set; } = "";
        [Required]
        [Column("password")]
        public override string PasswordHash { get; set; } = "";
        [Required]
        [Column("last_update_date", TypeName = "timestamp with time zone")]
        public DateTime LastUpdateDate { get; set; }
        [NotMapped]
        public override bool EmailConfirmed { get; set; }
        [NotMapped]
        public override string NormalizedUserName {
            get
            {
                return UserName.ToUpper();
            }
            set { /* DO nothing*/ }
        }
        [NotMapped]
        public override string NormalizedEmail {
            get
            {
                return Email.ToUpper();
            }
            set { /* DO nothing*/ }
        }
        [NotMapped]
        public override bool LockoutEnabled { get; set; }
        [NotMapped]
        public override int AccessFailedCount { get; set; }
        [NotMapped]
        public override string? PhoneNumber { get; set; }
        [NotMapped]
        public override string? ConcurrencyStamp { get; set; }
        [NotMapped]
        public override string? SecurityStamp { get; set; }
        [NotMapped]
        public override DateTimeOffset? LockoutEnd { get; set; }
        [NotMapped]
        public override bool TwoFactorEnabled { get; set; }
        [NotMapped]
        public override bool PhoneNumberConfirmed { get; set; }     
        public void Update(ApplicationUser user)
        {
            UserName = user.UserName;
            Organization = user.Organization;
            Email = user.Email;
            PasswordHash = user.PasswordHash;
        }
        public void Update(string userName, string? organization,
            string email, string password)
        {
            UserName = userName;
            Organization = organization;
            Email = email;
            // set hashed password text to PasswordHash.
            PasswordHash = new PasswordHasher<ApplicationUser>()
                .HashPassword(this, password);
        }
        public string Validate()
        {
            if(string.IsNullOrEmpty(UserName))
            {
                return "UserName is required";
            }
            if(string.IsNullOrEmpty(Email))
            {
                return "E-Mail address is required";
            }
            if(string.IsNullOrEmpty(PasswordHash))
            {
                return "Password is required";
            }
            return "";
        }
    }
}
```

I create a Database and add "ApplicationUser" table like this.
* [[ASP.NET Core][Entity Framework Core][Npgsql] Try code first & DB first](https://dev.to/masanori_msl/asp-net-core-entity-framework-core-npgsql-try-code-first-db-first-32pj)

### Unique Constraint
By default, UserName is constrained to be unique.
In this sample, I also need constraining Email.

#### ApprovementWorkflowContext.cs
```cs
using ApprovementWorkflowSample.Applications;
using Microsoft.EntityFrameworkCore;

namespace ApprovementWorkflowSample.Models
{
    public class ApprovementWorkflowContext: DbContext
    {
...
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<ApplicationUser>()
                .Property(w => w.LastUpdateDate)
                .HasDefaultValueSql("CURRENT_TIMESTAMP");
            modelBuilder.Entity<ApplicationUser>()
                .HasIndex(u => u.Email)
                .IsUnique();                
        }
        public DbSet<ApplicationUser> ApplicationUsers => Set<ApplicationUser>();
    }
}
```

### Role
This time, I won't add any special roles.
So I use default "IdentityRole".

#### Startup.cs
```cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
...
namespace ApprovementWorkflowSample
{
    public class Startup
    {
...
        public void ConfigureServices(IServiceCollection services)
        {
...
            services.AddDbContext<ApprovementWorkflowContext>(options =>
                options.UseNpgsql(configuration["DbConnection"]));
            // THIS PROJECT CAUSES AN ERROR
            services.AddIdentity<ApplicationUser, IdentityRole<int>>()
                .AddEntityFrameworkStores<ApprovementWorkflowContext>()
                .AddDefaultTokenProviders();
...
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseStaticFiles();
            app.UseRouting();

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

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}
```
### IdentityRole
When I use "IdentityUser&lt;int>", I must use "IdentityRole&lt;int>".
Or I will get an exception on runtime.

```
Stopped program because of exception System.ArgumentException: GenericArguments[1], 'Microsoft.AspNetCore.Identity.IdentityRole', on 'Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore`4[TUser,TRole,TContext,TKey]' violates the constraint of type 'TRole'.
 ---> System.TypeLoadException: GenericArguments[1], 'Microsoft.AspNetCore.Identity.IdentityRole', on 'Microsoft.AspNetCore.Identity.UserStoreBase`8[TUser,TRole,TKey,TUserClaim,TUserRole,TUserLogin,TUserToken,TRoleClaim]' violates the constraint of type parameter 'TRole'.
...
```
## Add custom UserStore
This project has a problem.
Because some properties of "ApplicationUser" are null.

For avoiding NullReferenceException, I must add a custom UserStore.
Because I will sign in by password, it implement "IUserPasswordStore".

#### ApplicationUserStore.cs
```cs
using System;
using System.Threading;
using System.Threading.Tasks;
using ApprovementWorkflowSample.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;

namespace ApprovementWorkflowSample.Applications
{
    public class ApplicationUserStore: IUserPasswordStore<ApplicationUser>
    {
        private readonly ILogger<ApplicationUserStore> logger;
        private readonly ApprovementWorkflowContext context;
        public ApplicationUserStore(ILogger<ApplicationUserStore> logger,
            ApprovementWorkflowContext context)
        {
            this.logger = logger;
            this.context = context;
        }
        public async Task<IdentityResult> CreateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            // validation
            string validationError = user.Validate();
            if(string.IsNullOrEmpty(validationError) == false)
            {
                return IdentityResult.Failed(new IdentityError { Description = validationError });
            }
            using(IDbContextTransaction transaction = context.Database.BeginTransaction())
            {
                if(await context.ApplicationUsers
                    .AnyAsync(u => u.Email == user.Email,
                    cancellationToken))
                {
                    return IdentityResult.Failed(new IdentityError { Description = "Your e-mail address is already used" });
                }
                var newUser = new ApplicationUser();
                newUser.Update(user);
                await context.ApplicationUsers.AddAsync(newUser, cancellationToken);
                await context.SaveChangesAsync(cancellationToken);
                await transaction.CommitAsync();
                return IdentityResult.Success;
            }
        }
        public async Task<IdentityResult> DeleteAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            ApplicationUser? target = await context.ApplicationUsers
                .FirstOrDefaultAsync(u => u.Id == user.Id,
                cancellationToken); 
            context.ApplicationUsers.Remove(target);
            await context.SaveChangesAsync(cancellationToken);
            return IdentityResult.Success;
        }
        public void Dispose() { /* do nothing */ }
        public async Task<ApplicationUser> FindByIdAsync(string userId, CancellationToken cancellationToken)
        {
            if(int.TryParse(userId, out var id) == false)
            {
                return new ApplicationUser();
            }
            return await context.ApplicationUsers
                .FirstOrDefaultAsync(u => u.Id == id,
                cancellationToken);
        }
        public async Task<ApplicationUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
        {
            return await context.ApplicationUsers
                .FirstOrDefaultAsync(u => u.UserName.ToUpper() == normalizedUserName,
                cancellationToken);
        }
        public async Task<string> GetNormalizedUserNameAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return await Task.FromResult(user.NormalizedUserName);
        }
        public async Task<string> GetPasswordHashAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return await Task.FromResult(user.PasswordHash);
        }
        public async Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return await Task.FromResult(user.Id.ToString());
        }
        public async Task<string> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return await Task.FromResult(user.UserName);
        }
        public async Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return await Task.FromResult(true);
        }
        public async Task SetNormalizedUserNameAsync(ApplicationUser user, string normalizedName, CancellationToken cancellationToken)
        {
            // do nothing
            await Task.Run(() => {});
        }
        public async Task SetPasswordHashAsync(ApplicationUser user, string passwordHash, CancellationToken cancellationToken)
        {
            using(IDbContextTransaction transaction = context.Database.BeginTransaction())
            {
                ApplicationUser? target = await context.ApplicationUsers
                    .FirstOrDefaultAsync(u => u.Id == user.Id,
                    cancellationToken);
                target.PasswordHash = passwordHash;
                // validation
                await context.SaveChangesAsync();
                await transaction.CommitAsync();
            }            
        }
        public async Task SetUserNameAsync(ApplicationUser user, string userName, CancellationToken cancellationToken)
        {
            using(IDbContextTransaction transaction = context.Database.BeginTransaction())
            {
                ApplicationUser? target = await context.ApplicationUsers
                    .FirstOrDefaultAsync(u => u.Id == user.Id,
                    cancellationToken);
                target.UserName = userName;
                // validation
                await context.SaveChangesAsync();
                await transaction.CommitAsync();
            }
        }
        public async Task<IdentityResult> UpdateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            string validationError = user.Validate();
            if(string.IsNullOrEmpty(validationError) == false)
            {
                return IdentityResult.Failed(new IdentityError { Description = validationError });
            }
            using(IDbContextTransaction transaction = context.Database.BeginTransaction())
            {
                ApplicationUser? target = await context.ApplicationUsers
                    .FirstOrDefaultAsync(u => u.Id == user.Id,
                    cancellationToken);
                // validation
                target.Update(user);
                await context.SaveChangesAsync(cancellationToken);
                await transaction.CommitAsync();
                return IdentityResult.Success;
            }
        }
    }
}
```
#### Startup.cs
```cs
...
namespace ApprovementWorkflowSample
{
    public class Startup
    {
...
        public void ConfigureServices(IServiceCollection services)
        {
...
            // OK
            services.AddIdentity<ApplicationUser, IdentityRole<int>>()
                .AddUserStore<ApplicationUserStore>()
                .AddEntityFrameworkStores<ApprovementWorkflowContext>()
                .AddDefaultTokenProviders();
...
```

### Resources
* [Introduction to Identity on ASP.NET Core | Microsoft Docs](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-5.0&tabs=visual-studio)
* [ASP.NET Identityカスタマイズに挑戦 - かずきのBlog@hatena](https://blog.okazuki.jp/entry/2013/11/10/190811)

## SignInManager
### SignIn & SignOut
I can sign in and sign out through "SignInManager".

#### ApplicationUserService.cs
```cs
using System.Security.Claims;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;

namespace ApprovementWorkflowSample.Applications
{
    public class ApplicationUserService: IApplicationUserService
    {
        private readonly ILogger<ApplicationUsers> logger;
        private readonly IApplicationUsers applicationUsers;
        private readonly SignInManager<ApplicationUser> signInManager;

        public ApplicationUserService(ILogger<ApplicationUsers> logger,
            IApplicationUsers applicationUsers,
            SignInManager<ApplicationUser> signInManager)
        {
            this.logger = logger;
            this.applicationUsers = applicationUsers;
            this.signInManager = signInManager;
        }
        public async Task<bool> SignInAsync(string email, string password)
        {
            var target = await applicationUsers.GetByEmailAsync(email);
            if (target == null)
            {
                return false;
            }
            var result = await signInManager.PasswordSignInAsync(target, password, false, false);
            return result.Succeeded;
        }
        public async Task SignOutAsync()
        {
            await signInManager.SignOutAsync();
        }        
    }
}
```

"PasswordSignInAsync" only can use "UserName" to sign in.
So if I want to sign in by e-mail, I must find user by e-mail first like above.

* [asp.net mvc - How to login using email in identity 2? - Stack Overflow](https://stackoverflow.com/questions/27498840/how-to-login-using-email-in-identity-2)

### Create users
"SignInManager" also can create users.

#### ApplicationUserService.cs
```cs
...
namespace ApprovementWorkflowSample.Applications
{
    public class ApplicationUserService: IApplicationUserService
    {
...
        public async Task<IdentityResult> CreateAsync(string userName, string? organization, string email, string password)
        {
            var newUser = new ApplicationUser();
            newUser.Update(userName, organization, email, password);
            return await signInManager.UserManager.CreateAsync(newUser);
        }
...
```
## Get signed in user infomations
After signing in, I can get user infomations from HttpContext.

#### ApplicationUserService.cs
```cs
using System.Security.Claims;
using System;
using System.Threading.Tasks;
using ApprovementWorkflowSample.Applications.Dto;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;

namespace ApprovementWorkflowSample.Applications
{
    public class ApplicationUserService: IApplicationUserService
    {
...
        private readonly SignInManager<ApplicationUser> signInManager;
        private readonly IHttpContextAccessor httpContextAccessor;

        public ApplicationUserService(ILogger<ApplicationUsers> logger,
            IApplicationUsers applicationUsers,
            SignInManager<ApplicationUser> signInManager,
            IHttpContextAccessor httpContextAccessor)
        {
...
            this.signInManager = signInManager;
            this.httpContextAccessor = httpContextAccessor;
        }
...
        public async ValueTask<User?> GetSignInUserAsync()
        {
            ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
            if (user == null)
            {
                return null;
            }
            if(signInManager.IsSignedIn(user) == false)
            {
                return null;
            }
            string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
            if(string.IsNullOrEmpty(userId) ||
                int.TryParse(userId, out var id) == false)
            {
                return null;
            }
            ApplicationUser? appUser = await applicationUsers.GetByIdAsync(id);
            if (appUser == null)
            {
                return null;
            }
            return new User(appUser.Id, appUser.UserName, appUser.Organization, appUser.Email);
        }
...
```