aspnet / Identity

[Archived] ASP.NET Core Identity is the membership system for building ASP.NET Core web applications, including membership, login, and user data. Project moved to https://github.com/aspnet/AspNetCore
Apache License 2.0
1.96k stars 870 forks source link

Custom IdentityUserLogin<int> and EF user store #1878

Closed dazinator closed 6 years ago

dazinator commented 6 years ago

I'm using the EF stores.

The only change I'd like to make is to add a column to the AspNetUserLogins table so that I can also store the refresh token associated with the external login. I think adding a column to an EF model should be fairly straightforward, and it was, but then getting identity to work with it is turning out to be a bit painful :-)

I derived my own entity from IdentityUserLogin<int> and added the additional property:

   public class DennisUserLogin : IdentityUserLogin<int>
   {
        public string RefreshToken { get; set; }
   }

And therefore had to derive my own IdentityDbContext, passing in that replacement type:


 public class DennisContext : IdentityDbContext<DennisUser, IdentityRole<int>, int, IdentityUserClaim<int>, IdentityUserRole<int>, DennisUserLogin, IdentityRoleClaim<int>, IdentityUserToken<int>>
    {
        public DennisContext(DbContextOptions<DennisContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);         

        }
    }

I was able to then add a new ef migration, and apply that, and I can see the additional column in the database - great.

Next I needed to be able to set this new property. So I had to find the locations where a new IdentityUserLogin entity is created, so that I could also set my additional property before its saved.

Unfortunately the UserStore seems to create this entity, and the there is nowhere in the existing API where I could pass an additional value to it (i.e referesh token). Therefore overriding any existing method on UserStore or UserManager wouldn't cater for my scenario. So I had to add some additional method to UserManager and UserStore that also took a "refresh token":


  public class DennisUserStore : UserStore<DennisUser, IdentityRole<int>, DennisContext, int>, IDennisUserStore
    {

        private readonly DennisContext _context;

        public DennisUserStore(DennisContext context, IdentityErrorDescriber describer = null) : base(context, describer)
        {
            _context = context;
        }

        /// <summary>
        /// Adds the <paramref name="login"/> given to the specified <paramref name="user"/>.
        /// </summary>
        /// <param name="user">The user to add the login to.</param>
        /// <param name="login">The login to add to the user.</param>
        /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
        /// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
        public Task AddLoginAsync(DennisUser user, UserLoginInfo login, string refreshToken,
            CancellationToken cancellationToken = default(CancellationToken))
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            if (user == null)
            {
                throw new ArgumentNullException(nameof(user));
            }
            if (login == null)
            {
                throw new ArgumentNullException(nameof(login));
            }
            var userLogins = _context.UserLogins;
            var newLogin = CreateDennisUserLogin(user, login);
            newLogin.RefreshToken = refreshToken;
            userLogins.Add(newLogin);
            return Task.FromResult(false);
        }

        private DennisUserLogin CreateDennisUserLogin(DennisUser user, UserLoginInfo login)
        {
            var dennisUserLogin = new DennisUserLogin();
            dennisUserLogin.LoginProvider = login.LoginProvider;
            dennisUserLogin.ProviderDisplayName = login.ProviderDisplayName;
            dennisUserLogin.ProviderKey = login.ProviderKey;
            dennisUserLogin.UserId = user.Id;
            return dennisUserLogin;
        }

    }

and UserManager:


   public class DennisUserManager : UserManager<DennisUser>
    {
        public DennisUserManager(IUserStore<DennisUser> store, IOptions<IdentityOptions> optionsAccessor, IPasswordHasher<DennisUser> passwordHasher, IEnumerable<IUserValidator<DennisUser>> userValidators, IEnumerable<IPasswordValidator<DennisUser>> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger<UserManager<DennisUser>> logger) : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger)
        {

        }

        // IUserLoginStore methods
        private IDennisUserStore GetStore()
        {

            var cast = Store as IDennisUserStore;
            if (cast == null)
            {
                throw new NotSupportedException("Store Not IUserLoginStore");
            }
            return cast;
        }     

        public async Task<IdentityResult> AddLoginAsync(DennisUser user, UserLoginInfo login, string refreshToken)
        {
            ThrowIfDisposed();
            var loginStore = GetStore();
            if (login == null)
            {
                throw new ArgumentNullException(nameof(login));
            }
            if (user == null)
            {
                throw new ArgumentNullException(nameof(user));
            }

            var existingUser = await FindByLoginAsync(login.LoginProvider, login.ProviderKey);
            if (existingUser != null)
            {
                Logger.LogWarning(4, "AddLogin for user {userId} failed because it was already associated with another user.", await GetUserIdAsync(user));
                return IdentityResult.Failed(ErrorDescriber.LoginAlreadyAssociated());
            }
            await loginStore.AddLoginAsync(user, login, refreshToken, CancellationToken);
            return await UpdateUserAsync(user);
        }
    }

So far this is a lot of work in order to be able to add one property to this entity but I thought I was close.

I changed the razor UI code to call the new method on my UserManager instead which takes the refresh token,

Now when I run the app, when attempting an external login, I get the following error:

InvalidOperationException: Cannot create a DbSet for 'IdentityUserLogin' because this type is not included in the model for the context. Microsoft.EntityFrameworkCore.Internal.InternalDbSet.get_EntityType() Microsoft.EntityFrameworkCore.Internal.InternalDbSet.get_EntityQueryable() Microsoft.EntityFrameworkCore.Internal.InternalDbSet.System.Linq.IQueryable.get_Provider() Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ExecuteAsync<TSource, TResult>(MethodInfo operatorMethodInfo, IQueryable source, Expression expression, CancellationToken cancellationToken) Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ExecuteAsync<TSource, TResult>(MethodInfo operatorMethodInfo, IQueryable source, LambdaExpression expression, CancellationToken cancellationToken) Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(IQueryable source, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken) Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>.FindUserLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken) Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>.FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken) Microsoft.AspNetCore.Identity.SignInManager.ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) Dennis.Areas.Identity.Pages.Account.ExternalLoginModel.OnGetCallbackAsync(string returnUrl, string remoteError) in ExternalLogin.cshtml.cs

{ ErrorMessage = "Error loading external login information."; return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); } // Sign in the user with this external login provider if the user already has a login. var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor : true); if (result.Succeeded) { var tokens = info.AuthenticationTokens.ToArray(); // save the users _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider); return LocalRedirect(returnUrl); Microsoft.AspNetCore.Mvc.RazorPages.Internal.ExecutorFactory+GenericTaskHandlerMethod.Convert(object taskAsObject)

It seems something about the store is still specifically looking for IdentityUserLogin<int> which isn't part of my model anymore - because I am using my own derived type DennisUserLogin. However it doesn't appear I can inform the user store of that.. What am I missing?

dazinator commented 6 years ago

Ah damn, I missed the overload of UserStore that takes the type I needed.

I should have declared it like this:

 public class DennisUserStore : UserStore<DennisUser, IdentityRole<int>, DennisContext, int, IdentityUserClaim<int>, IdentityUserRole<int>, DennisUserLogin, IdentityUserToken<int>, IdentityRoleClaim<int>>, IDennisUserStore
    {
ajcvickers commented 6 years ago

@dazinator For future reference, see https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize_identity_model?view=aspnetcore-2.1

Feedback on the doc appreciated.

Ibro commented 5 years ago

Updated link: https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-model?view=aspnetcore-2.2