I am currently investigating the viability of converting an old ASP.NET Webforms website to use .NET Core (soon .NET 5) RazorPages. I wanted to use all of the built-in security. However, we already have a user table with salted and hashed passwords. Its schema is not compatible with the default implementation of the Identity model, so I spent a few days working it out.

This post will serve as my notebook for how to accomplish this feat.

The first step I took was to go ahead and scaffold all of the Identity pages into my RazorPages project using this guide. It's important to follow all the instructions. I spent about four hours debugging the app (even digging into the .NET Core source code) before I realized that I had failed to use this line of code:

app.UseAuthentication();

in the Startup.cs file.

Once you have scaffolded the Identity pages, you will want to open Login.cshtml.cs and change the OnPostAsync sign in to use lockout if you want to enable it.

// To enable password failures to trigger account lockout, set lockoutOnFailure: true
            var result = await _signInManager.PasswordSignInAsync(Input.Username, Input.Password, Input.RememberMe, lockoutOnFailure: true);

The rest of my troubles were basically learning what I did and did not have to implement myself.

The next step is to define your custom ApplicationUser that derives from IdentityUser<TKey>. Make sure you use a type for the key that is compatible with your primary key on your user table.

public class ApplicationUser : IdentityUser<long>
{
    // Account lockout is enabled for all users - omit this code if you have a column for this in your user table
    public override bool LockoutEnabled { get; set; } = true;

    // Need to convert the DateTimeOffset (contains the time zone)
    // to a local date that's stored in the database
    public override DateTimeOffset? LockoutEnd
    {
        get => LastLockoutDate;
        set => LastLockoutDate = value?.LocalDateTime;
    }
    public DateTime? LastLockoutDate { get; set; }

    public override string UserName
    {
        get => NormalizedUserName;
        set { }
    }
}

I had to modify my ApplicationUser to override the LockoutEnabled property. That's because we don't have this column in the User table and I just want all users to have lockout enabled. Account Lockout will not work without this default value or a column in the database set to true.

I also did a little indirection for the LockoutEnd property to convert the DateTimeOffset to a DateTime to match our database column.

The UserName shares its value with the NormalizedUserName since we only have one UserId column in the User table.

I am not comfortable with using EF Core Database Migrations. As such, I simply wrote the OnModelCreating in my ApplicationCoreContext to map the ApplicationUser to the User table. This allowed me to use the remaining Identity classes.

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);
    builder.Entity<ApplicationUser>(b =>
    {
        b.ToTable("TheUserTableName");

        b.HasKey(u => u.Id);
        b.Property(p => p.Id)
            .HasColumnName("Ind");
        b.Property(p => p.NormalizedUserName)
            .HasColumnName("UserId")
            .HasMaxLength(25);
        b.Property(p => p.Email)
            .HasMaxLength(50);
        b.Property(p => p.PasswordHash)
            .HasColumnName("HashedPassword")
            .HasMaxLength(250);
        b.Property(p => p.DateOfBirth)
            .HasColumnName("DOB")
            .HasColumnType("datetime");
        b.Property(p => p.LastLockoutDate)
            .HasColumnName("LastLockOutDate")
            .HasColumnType("datetime");
        b.Property(p => p.AccessFailedCount)
            .HasColumnName("AccessFailedCount");
        b.Property(p => p.SecurityStamp)
            .HasColumnName("SecurityStamp");
        b.Property(p => p.ConcurrencyStamp)
            .HasColumnName("ConcurrencyStamp");

        b.Property(p => p.FirstName)
            .HasColumnName("FName");
        b.Property(p => p.MiddleName)
            .HasColumnName("MName");
        b.Property(p => p.LastName)
            .HasColumnName("LName");


        b.Ignore(p => p.UserName);
        b.Ignore(p => p.NormalizedEmail);
        b.Ignore(p => p.EmailConfirmed);
        b.Ignore(p => p.LockoutEnabled);
        b.Ignore(p => p.LockoutEnd);
        b.Ignore(p => p.PhoneNumber);
        b.Ignore(p => p.PhoneNumberConfirmed);
        b.Ignore(p => p.TwoFactorEnabled);
    });
}

To get the login to work with just a single table (our website is not using roles), I had to implement a custom class that implements the IUserClaimsPrincipalFactory<TUser> interface.

public class ApplicationClaimsPrincipalFactory<TUser> : IUserClaimsPrincipalFactory<TUser> where TUser: ApplicationUser
{
    public async Task<ClaimsPrincipal> CreateAsync(TUser user)
    {
        if (user == null) throw new ArgumentNullException(nameof(user));

        var id = await GenerateClaimsAsync(user);
        return new ClaimsPrincipal(id);
    }

    protected async Task<ClaimsIdentity> GenerateClaimsAsync(TUser user)
    {
        var id = new ClaimsIdentity(IdentityConstants.ApplicationScheme, // Used to match application scheme
            ClaimTypes.NameIdentifier,
            ClaimTypes.Role);

        id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
        id.AddClaim(new Claim(ClaimTypes.Name, user.UserName.ToLower()));
        id.AddClaim(new Claim(ApplicationClaimTypes.UserType, "Special"));

        return id;
    }
}

I borrowed some of this code from the .NET Core source code from their default implementation. I modified it a bit to add some simple claims for the User's Id and UserName.

To get the login to work with our specific hashing algorithm, I used code from Andrew Lock in his post Migrating passwords in ASP.NET Core Identity with a custom PasswordHasher.

This conveniently reads the old hashed password from our user table and replaces it with the .NET Core identity hash.

For completion sake (in case the website goes down or the URL changes), I am including his code here. I did not write this code, it is from the above post:

/// <summary>
/// A drop-in replacement for the standard Identity hasher to be backwards compatible with existing bcrypt hashes
/// New passwords will be hashed with Identity V3
/// </summary>
public class BCryptPasswordHasher<TUser> : PasswordHasher<TUser> where TUser : class
{
    readonly BCryptPasswordSettings _settings;
    public BCryptPasswordHasher(BCryptPasswordSettings settings)
    {
        _settings = settings;
    }

    public override PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword, string providedPassword)
    {
        if (hashedPassword == null) { throw new ArgumentNullException(nameof(hashedPassword)); }
        if (providedPassword == null) { throw new ArgumentNullException(nameof(providedPassword)); }

        byte[] decodedHashedPassword = Convert.FromBase64String(hashedPassword);

        // read the format marker from the hashed password
        if (decodedHashedPassword.Length == 0)
        {
            return PasswordVerificationResult.Failed;
        }

        // ASP.NET Core uses 0x00 and 0x01, so we start at the other end
        if (decodedHashedPassword[0] == 0xFF)
        {
            if (VerifyHashedPasswordBcrypt(decodedHashedPassword, providedPassword))
            {
                // This is an old password hash format - the caller needs to rehash if we're not running in an older compat mode.
                return _settings.RehashPasswords
                    ? PasswordVerificationResult.SuccessRehashNeeded
                    : PasswordVerificationResult.Success;
            }
            else
            {
                return PasswordVerificationResult.Failed;
            }
        }

        return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
    }

    private static bool VerifyHashedPasswordBcrypt(byte[] hashedPassword, string password)
    {
        if (hashedPassword.Length < 2)
        {
            return false; // bad size
        }

        //convert back to string for BCrypt, ignoring first byte
        var storedHash = Encoding.UTF8.GetString(hashedPassword, 1, hashedPassword.Length - 1);

        return BCrypt.Verify(password, storedHash);
    }
}

We are not using BCrypt, but I modified that code to match our algorithm.

In Startup.cs in the ConfigureServices method, I add security to a specific Area (this is optional, but I have different Areas for different kinds of users):

services.AddRazorPages(o => {
                o.Conventions.AuthorizeAreaFolder("SpecialArea", "/", "Special");
            });

Notice that I passed in the third argument, which indicates which Policy to use when evaluating authorization for that area. I will have another area for a different kind of user.

And then in my IdentityHostingStartup.cs file, I added the code to add the policy:

services.AddAuthorization(options =>
                    options.AddPolicy("Special",
                        policy => policy.RequireClaim(ApplicationClaimTypes.UserType, "Special", "Administrator")));

I also add the Identity and Application Cookie:

services.ConfigureApplicationCookie(options =>
{
    options.Cookie.HttpOnly = true;
    options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
    options.LoginPath = "/Identity/Account/Login";
    options.AccessDeniedPath = "/Identity/Account/AccessDenied";
    options.SlidingExpiration = true;
});

services.AddDefaultIdentity<ApplicationUser>(options =>
{
    options.Lockout.AllowedForNewUsers = true;
    options.Lockout.MaxFailedAccessAttempts = 3;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
    options.User.AllowedUserNameCharacters += " _";
    options.User.RequireUniqueEmail = false;
    options.SignIn.RequireConfirmedAccount = false;
    options.SignIn.RequireConfirmedEmail = false;
    options.SignIn.RequireConfirmedPhoneNumber = false;
})
.AddEntityFrameworkStores<ApplicationCoreContext>()
.AddDefaultTokenProviders();

Here is the ApplicationClaimTypes static class definition:

public static class ApplicationClaimTypes
{
    public static string UserType = "userType";
}

I haven't finished the rest of the work, but I plan to remove the external logins and enable to two-factor authentication. I will also go through the scaffolded code and remove and add things as necessary to fit our needs.