April 29, 2019

Using a custom DB schema with asp.net Core Identity.

The Identity Framework from Microsoft for Asp.Net and Asp.Net Core is a great and simple way to implement membership into your site without writing too much code, however, there isn't much available that I could find on how to customise the implementation.

Source Code: GitHub

The Identity Framework from Microsoft for Asp.Net and Asp.Net Core is a great and simple way to implement  membership into your site without writing too much code, however, there isn't much available that I could find on how to customise the implementation in a Core application to use your own existing member/user data in a database instead of using the database and schema created by the framework. But, for a developer, my Googling skills are very sub-par so I probably wasn't using the right combination of keywords to find the right information.

So after a bit of reading and looking at various bits of people's code, I came to a solution that works for me. This is probably not the only way to achieve it and there may even be a better way of doing it, but it works so for now its how I will go about it.

So What do we need to do?

The Identity Framework provides a lot of plumbing for you and it also enables you to customise as much, or as little, as you want. We only really need to focus on two things:

  • User Store
  • Role Store

These two items are the point where the Identity framework meets Entity Framework.

NB: In the example code you will see I have also created two other classes UserManager and RoleManager, these don't really customise anything for the user and role managers but it makes my code neater for me, IE: wraps up the generics for me.

Code Time

Right, so before I show you the two stores, let me show you the model classes I have to describe the users, roles, and the link between the two.

[Table("ApplicationUser")]
    public class CusUser
    {
        public CusUser()
        {
        }

        [Key]
        public int Id {  get;set; }

        [Required]
        public string Firstname {  get;set; }

        [Required]
        public string Lastname {  get;set; }

        [Required]
        [DataType(DataType.EmailAddress)]
        public string Email {  get;set; }

        [Required]
        public string PasswordHash {  get;set; }

        public virtual ICollection<CusUserRole> UserRoles {  get;set; }
    }
    
    [Table("Role")]
    public class CusRole
    {
        public CusRole()
        {
        }

        [Key]
        public int Id {  get;set; }

        [Required]
        public string Name {  get;set; }

        public virtual ICollection<CusUserRole> UserRoles {  get;set; }
    }
    
    [Table("ApplicationUserRoles")]
    public class CusUserRole
    {
        public CusUserRole()
        {
        }

        [Key]
        public int Id {  get;set; }

        [Required]
        [ForeignKey(nameof(CusUser))]
        public int UserId {  get;set; }

        [Required]
        [ForeignKey(nameof(CusRole))]
        public int RoleId {  get;set; }

        public virtual CusUser User {  get;set; }
        public virtual CusRole Role {  get;set; }
    }

There is nothing special about the above classes. Yes, I normally expand a bit, and have a set of base classes which store the Id and various other properties common to a set of data classes, but for completeness, I haven't done that here.

Role Store

Lets start with the role store, since this is simple and easy to get through.

The custom role store must just implment the IRoleStore<T> interface. In our class, we take in the EF DbContext in the constructor and set a readonly attribute, so we now have access to our existing schema.

public class CustomRoleStore : IRoleStore<CusRole>
    {
        public CustomRoleStore(CustomDataContext context)
        {
            _context = context;
        }

        private readonly CustomDataContext _context;

        public async Task<IdentityResult> CreateAsync(CusRole role, CancellationToken cancellationToken)
        {
            _context.Add(role);

            await _context.SaveChangesAsync(cancellationToken);

            return await Task.FromResult(IdentityResult.Success);
        }

        public async Task<IdentityResult> DeleteAsync(CusRole role, CancellationToken cancellationToken)
        {
            _context.Remove(role);

            int i = await _context.SaveChangesAsync(cancellationToken);

            return await Task.FromResult(i == 1 ? IdentityResult.Success : IdentityResult.Failed());
        }


        public async Task<CusRole> FindByIdAsync(string roleId, CancellationToken cancellationToken)
        {
            if (int.TryParse(roleId, out int id))
            {
                return await _context.Roles.FindAsync(id);
            }
            else
            {
                return await Task.FromResult((CusRole)null);
            }
        }

        public async Task<CusRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
        {
            return await _context.Roles
                           .AsAsyncEnumerable()
                           .SingleOrDefault(p => p.Name.Equals(normalizedRoleName, StringComparison.OrdinalIgnoreCase), cancellationToken);
        }

        public Task<string> GetNormalizedRoleNameAsync(CusRole role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<string> GetRoleIdAsync(CusRole role, CancellationToken cancellationToken)
        {
            return Task.FromResult(role.Id.ToString());
        }

        public Task<string> GetRoleNameAsync(CusRole role, CancellationToken cancellationToken)
        {
            return Task.FromResult(role.Name);
        }

        public Task SetNormalizedRoleNameAsync(CusRole role, string normalizedName, CancellationToken cancellationToken)
        {
            return Task.FromResult((object)null);
        }

        public Task SetRoleNameAsync(CusRole role, string roleName, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<IdentityResult> UpdateAsync(CusRole role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        #region IDisposable Support
        private bool disposedValue = false; // To detect redundant calls

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    _context.Dispose();
                }

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            Dispose(true);
        }
        #endregion
    }

The role store also follows the Disposal pattern.

While it looks like a lot, you just need to implement the interface and then plugin in your DbContext. Thankfully, the naming of the methods makes it pretty easy to know what you need to do.

User Store

The User store requires two interfaces IUserRoleStore<T> and IUserPasswordStore<T>.

If you don't need to worry about the links between the User and Roles, IE: you don't want to implement any roles then just implement IUserStore<T>.

The reason why there are two Interfaces on our user store is because IUserRoleStore doesn't implement methods to handle the user's password, so we also implement IUserPasswordStore<T>. If we don't implement this interface, at runtime the Identity framework will complain bitterly.

public class CustomUserStore : IUserRoleStore<CusUser>, IUserPasswordStore<CusUser>
    {
        public CustomUserStore(CustomDataContext context)
        {
            _context = context;
        }

        private readonly CustomDataContext _context;

        public Task AddToRoleAsync(CusUser user, string roleName, CancellationToken cancellationToken)
        {
            var role = _context.Roles.Where(item => item.Name.Equals(roleName)).FirstOrDefault();

            if(role != null)
            { 
                CusUserRole assignment = new CusUserRole() {  Id = role.Id, UserId = user.Id };
                _context.UserRoles.Add(assignment);
                _context.SaveChanges();
            }

            return Task.FromResult((CusUser)null);
        }

        public async Task<IdentityResult> CreateAsync(CusUser user, CancellationToken cancellationToken)
        {
            _context.Add(user);

            await _context.SaveChangesAsync(cancellationToken);

            return await Task.FromResult(IdentityResult.Success);
        }

        public async Task<IdentityResult> DeleteAsync(CusUser user, CancellationToken cancellationToken)
        {
            _context.Remove(user);

            int i = await _context.SaveChangesAsync(cancellationToken);

            return await Task.FromResult(i == 1 ? IdentityResult.Success : IdentityResult.Failed());
        }

        public async Task<CusUser> FindByIdAsync(string userId, CancellationToken cancellationToken)
        {
            if (int.TryParse(userId, out int id))
            {
                return await _context.Users.FindAsync(id);
            }
            else
            {
                return await Task.FromResult((CusUser)null);
            }
        }

        public async Task<CusUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
        {
            return await _context.Users
                           .AsAsyncEnumerable()
                           .SingleOrDefault(p => p.Email.Equals(normalizedUserName, StringComparison.OrdinalIgnoreCase), cancellationToken);
        }

        public Task<string> GetNormalizedUserNameAsync(CusUser user, CancellationToken cancellationToken)
        {
            throw new NotImplementedException(nameof(GetNormalizedUserNameAsync));
        }

        public Task<IList<string>> GetRolesAsync(CusUser user, CancellationToken cancellationToken)
        {
            var assignments = _context.UserRoles.Include(record => record.Role).Where(item => item.UserId.Equals(user.Id));

            List<string> roles = new List<string>();

            foreach(var record in assignments)
            {
                roles.Add(record.Role.Name);
            }

            return Task.FromResult<IList<string>>(roles);
        }

        public Task<string> GetUserIdAsync(CusUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.Id.ToString());
        }

        public Task<string> GetUserNameAsync(CusUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.Email);
        }

        public Task<IList<CusUser>> GetUsersInRoleAsync(string roleName, CancellationToken cancellationToken)
        {
            IList<CusUser> users = new List<CusUser>();

            var role = _context.Roles.Where(item => item.Name.Equals(roleName)).FirstOrDefault();

            if(role != null)
            {
                var assignments = _context.UserRoles.Where(item => item.RoleId.Equals(role.Id));

                foreach(var record in assignments)
                {
                    users.Add(record.User);
                }
            }

            return Task.FromResult<IList<CusUser>>(users);
        }

        public Task<bool> IsInRoleAsync(CusUser user, string roleName, CancellationToken cancellationToken)
        {
            bool inRole = false;

            var role = _context.Roles.Where(item => item.Name.Equals(roleName)).FirstOrDefault();

            if(role != null)
            { 
                var assignment = _context.UserRoles.Where(item => item.UserId.Equals(user.Id) && item.RoleId.Equals(role.Id)).FirstOrDefault();

                inRole = assignment != null;
            }

            return Task.FromResult<bool>(inRole);
        }

        public Task RemoveFromRoleAsync(CusUser user, string roleName, CancellationToken cancellationToken)
        {
            var role = _context.Roles.Where(item => item.Name.Equals(roleName)).FirstOrDefault();

            if(role != null)
            {
                var assignments = _context.UserRoles.Where(item => item.UserId.Equals(user.Id) && item.RoleId.Equals(role.Id));

                _context.UserRoles.RemoveRange(assignments.ToArray());
                _context.SaveChanges();
            }

            return Task.FromResult<CusUser>(null);
        }

        public Task SetNormalizedUserNameAsync(CusUser user, string normalizedName, CancellationToken cancellationToken)
        {
            return Task.FromResult((object)null);
        }

        public Task SetUserNameAsync(CusUser user, string userName, CancellationToken cancellationToken)
        {
            throw new NotImplementedException(nameof(SetUserNameAsync));
        }

        public async Task<IdentityResult> UpdateAsync(CusUser user, CancellationToken cancellationToken)
        {
            try
            { 
                _context.Users.Update(user);
                await _context.SaveChangesAsync();
            }
            catch(Exception ex)
            {
                return IdentityResult.Failed();
            }

            return IdentityResult.Success;
        }

        public Task SetPasswordHashAsync(CusUser user, string passwordHash, CancellationToken cancellationToken)
        {
            user.PasswordHash = passwordHash;

            return Task.FromResult((object)null);
        }

        public Task<string> GetPasswordHashAsync(CusUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.PasswordHash);
        }

        public async Task<bool> HasPasswordAsync(CusUser user, CancellationToken cancellationToken)
        {
            return !String.IsNullOrWhiteSpace(user.PasswordHash);
        }

        #region IDisposable Support
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                _context?.Dispose();
            }
        }
        #endregion
    }

Again it is as simple as just implementing the methods using your Entity DbContext.

Wire it up.

The last thing we need to do is wire up everything in the startup.cs file.

Add the folowing to the ConfigureServices method:

services.AddIdentity<CusUser, CusRole>().AddDefaultTokenProviders();

services.AddTransient<IUserRoleStore<CusUser>, CustomUserStore>();
services.AddTransient<IRoleStore<CusRole>, CustomRoleStore>();

Also, add the following to the Configure method, before app.UseMvc():

app.UseAuthentication();

So it is very simple in the end to wire up your own DB schema into the Identity Framework.