8

I'm trying something quite exotic I believe and I'm facing a few problems, which I hope can be solved with the help of the users here on StackOverflow.

The story

I'm writing and application which requires authentication and registration. I've chosen to use Microsoft.AspNet.Identity. I've not used it a lot in the past, so don't judge me on this decision.

The framework mentioned above contains a users table, that holds all the registered users.

I've created a sample picture to show how the application will be working.

enter image description here

The application consists out of 3 various components:

  1. Backend (WebAPI).
  2. Customers (Using the WebAPI directly).
  3. End Users (Using a Mobile App - iOS).

So, I do have a backend on which Customers can register. There is one unique user per member, so no problem here. A customer is a company here and End Users are cliënts of the company.

You might see the problem already, It's perfectly possible that User 1 is a cliënt at Customer 1 but also at Customer 2.

Now, a customer can invite a member to use the Mobile application. When a customer does that, the end user does receive an e-mail with a link to active himself.

Now, that's all working fine as long as your users are unique, but I do have a user which is a cliënt of Customer 1 and Customer 2. Both customers can invite the same user and the user will need to register 2 times, one for each Customer.

The problem

In Microsoft.AspNet.Identity framework, users should be unique, which according to my situation, I'm not able to manage.

The question

Is it possible to add extra parameters to the IdentityUser that make sure a user is unique?

What I've done already

  1. Create a custom class that inherits from IdentityUser and which includes an application id:

    public class AppServerUser : IdentityUser
    {
        #region Properties
    
        /// <summary>
        ///     Gets or sets the id of the member that this user belongs to.
        /// </summary>
        public int MemberId { get; set; }
    
        #endregion
    }
    
  2. Changed my IDbContext accordingly:

    public class AppServerContext : IdentityDbContext<AppServerUser>, IDbContext { }
    
  3. Modified calls that are using the framework.

    IUserStore<IdentityUser> -> IUserStore<AppServerUser>
    UserManager<IdentityUser>(_userStore) -> UserManager<AppServerUser>(_userStore);
    

    _userStore is off course of type IUserStore<AppServerUser>

However, when I do register a user with a username that is already taken, I still receive an error message saying that the username is already taken:

var result = await userManager.CreateAsync(new AppServerUser {UserName = "testing"}, "testing");

What I do believe is a solution

I do believe that I need to change the UserManager but I'm not sure about it. I do hope that someone here has enough knowledge about the framework to help me out because it's really blocking our application development.

If it isn't possible, I would like to know also, and maybe you can point me to another framework that allows me to do this.

Note: I don't want to write a whole user management myself because that will be reïnventing the wheel.

Complexity
  • 5,682
  • 6
  • 41
  • 84
  • FYI, this is called "Multi-Tenancy". It's a common concept in websites, and a well known problem. You will find many questions on that here with Identity Framework. – Erik Funkenbusch Apr 14 '15 at 14:17
  • It's not clear to me whether you want separate distinct records for each customer/user or whether you want a single user for multiple customers. – Erik Funkenbusch Apr 14 '15 at 14:19
  • There will need to be 2 user records in the user table but both records will have the same username and password. There will be another field that uniquely identifies a record. – Complexity Apr 14 '15 at 14:22
  • 1
    You may find this useful http://stackoverflow.com/questions/20037145/how-to-implement-multi-tenant-user-login-using-asp-net-identity – Erik Funkenbusch Apr 14 '15 at 14:24

2 Answers2

3

May be someone can find this helpful. In our project we use ASP.NET identity 2 and some day we came across case where two users have identical names. We use emails as logins in our app and they, indeed, have to be unique. But we don't want to have user names unique anyway. What we did just customized few classes of identity framework as follows:

  1. Changed our AppIdentityDbContext by creating index on UserName field as non-unique and override ValidateEntity in tricky way. And then using migrations update database. Code looks like:

    public class AppIdentityDbContext : IdentityDbContext<AppUser>
    {
    
    public AppIdentityDbContext()
        : base("IdentityContext", throwIfV1Schema: false)
    {
    }
    
    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder); // This needs to go before the other rules!
    
         *****[skipped some other code]*****
    
        // In order to support multiple user names 
        // I replaced unique index of UserNameIndex to non-unique
        modelBuilder
        .Entity<AppUser>()
        .Property(c => c.UserName)
        .HasColumnAnnotation(
            "Index", 
            new IndexAnnotation(
            new IndexAttribute("UserNameIndex")
            {
                IsUnique = false
            }));
    
        modelBuilder
            .Entity<AppUser>()
            .Property(c => c.Email)
            .IsRequired()
            .HasColumnAnnotation(
                "Index",
                new IndexAnnotation(new[]
                {
                    new IndexAttribute("EmailIndex") {IsUnique = true}
                }));
    }
    
    /// <summary>
    ///     Override 'ValidateEntity' to support multiple users with the same name
    /// </summary>
    /// <param name="entityEntry"></param>
    /// <param name="items"></param>
    /// <returns></returns>
    protected override DbEntityValidationResult ValidateEntity(DbEntityEntry entityEntry,
        IDictionary<object, object> items)
    {
        // call validate and check results 
        var result = base.ValidateEntity(entityEntry, items);
    
        if (result.ValidationErrors.Any(err => err.PropertyName.Equals("User")))
        {
            // Yes I know! Next code looks not good, because I rely on internal messages of Identity 2, but I should track here only error message instead of rewriting the whole IdentityDbContext
    
            var duplicateUserNameError = 
                result.ValidationErrors
                .FirstOrDefault(
                err =>  
                    Regex.IsMatch(
                        err.ErrorMessage,
                        @"Name\s+(.+)is\s+already\s+taken",
                        RegexOptions.IgnoreCase));
    
            if (null != duplicateUserNameError)
            {
                result.ValidationErrors.Remove(duplicateUserNameError);
            }
        }
    
        return result;
    }
    }
    
  2. Create custom class of IIdentityValidator<AppUser> interface and set it to our UserManager<AppUser>.UserValidator property:

    public class AppUserValidator : IIdentityValidator<AppUser>
    {
    /// <summary>
    ///     Constructor
    /// </summary>
    /// <param name="manager"></param>
    public AppUserValidator(UserManager<AppUser> manager)
    {
        Manager = manager;
    }
    
    private UserManager<AppUser, string> Manager { get; set; }
    
    /// <summary>
    ///     Validates a user before saving
    /// </summary>
    /// <param name="item"></param>
    /// <returns></returns>
    public virtual async Task<IdentityResult> ValidateAsync(AppUser item)
    {
        if (item == null)
        {
            throw new ArgumentNullException("item");
        }
    
        var errors = new List<string>();
    
        ValidateUserName(item, errors);
        await ValidateEmailAsync(item, errors);
    
        if (errors.Count > 0)
        {
            return IdentityResult.Failed(errors.ToArray());
        }
        return IdentityResult.Success;
    }
    
    private void ValidateUserName(AppUser user, List<string> errors)
    {
        if (string.IsNullOrWhiteSpace(user.UserName))
        {
            errors.Add("Name cannot be null or empty.");
        }
        else if (!Regex.IsMatch(user.UserName, @"^[A-Za-z0-9@_\.]+$"))
        {
            // If any characters are not letters or digits, its an illegal user name
            errors.Add(string.Format("User name {0} is invalid, can only contain letters or digits.", user.UserName));
        }
    }
    
    // make sure email is not empty, valid, and unique
    private async Task ValidateEmailAsync(AppUser user, List<string> errors)
    {
        var email = user.Email;
    
        if (string.IsNullOrWhiteSpace(email))
        {
            errors.Add(string.Format("{0} cannot be null or empty.", "Email"));
            return;
        }
        try
        {
            var m = new MailAddress(email);
        }
        catch (FormatException)
        {
            errors.Add(string.Format("Email '{0}' is invalid", email));
            return;
        }
        var owner = await Manager.FindByEmailAsync(email);
        if (owner != null && !owner.Id.Equals(user.Id))
        {
            errors.Add(string.Format(CultureInfo.CurrentCulture, "Email '{0}' is already taken.", email));
        }
    }
    }
    
    public class AppUserManager : UserManager<AppUser>
    {
    public AppUserManager(
        IUserStore<AppUser> store,
        IDataProtectionProvider dataProtectionProvider,
        IIdentityMessageService emailService)
        : base(store)
    {
    
        // Configure validation logic for usernames
        UserValidator = new AppUserValidator(this);
    
  3. And last step is change AppSignInManager. Because now our user names is not unique we use email to log in:

    public class AppSignInManager : SignInManager<AppUser, string>
    {
     ....
    public virtual async Task<SignInStatus> PasswordSignInViaEmailAsync(string userEmail, string password, bool isPersistent, bool shouldLockout)
    {
        var userManager = ((AppUserManager) UserManager);
        if (userManager == null)
        {
            return SignInStatus.Failure;
        }
    
        var user = await UserManager.FindByEmailAsync(userEmail);
        if (user == null)
        {
            return SignInStatus.Failure;
        }
    
        if (await UserManager.IsLockedOutAsync(user.Id))
        {
            return SignInStatus.LockedOut;
        }
    
        if (await UserManager.CheckPasswordAsync(user, password))
        {
            await UserManager.ResetAccessFailedCountAsync(user.Id);
            await SignInAsync(user, isPersistent, false);
            return SignInStatus.Success;
        }
    
        if (shouldLockout)
        {
            // If lockout is requested, increment access failed count which might lock out the user
            await UserManager.AccessFailedAsync(user.Id);
            if (await UserManager.IsLockedOutAsync(user.Id))
            {
                return SignInStatus.LockedOut;
            }
        }
        return SignInStatus.Failure;
    }
    

    And now code looks like:

    [HttpPost]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<ActionResult> Index(User model, string returnUrl)
    {
        if (!ModelState.IsValid)
        {
            return View(model);
        }
        var result = 
            await signInManager.PasswordSignInViaEmailAsync(
                model.Email,
                model.Password, 
                model.StaySignedIn,
                true);
    
        var errorMessage = string.Empty;
        switch (result)
        {
            case SignInStatus.Success:
                if (IsLocalValidUrl(returnUrl))
                {
                    return Redirect(returnUrl);
                }
    
                return RedirectToAction("Index", "Home");
            case SignInStatus.Failure:
                errorMessage = Messages.LoginController_Index_AuthorizationError;
                break;
            case SignInStatus.LockedOut:
                errorMessage = Messages.LoginController_Index_LockoutError;
                break;
            case SignInStatus.RequiresVerification:
                throw new NotImplementedException();
        }
    
        ModelState.AddModelError(string.Empty, errorMessage);
        return View(model);
    }
    

P.S. I don't really like how I override ValidateEntity method. But I decided to do this because instead I have to implement DbContext class almost identical to IdentityDbContext, thus I have to track changes on it when update identity framework package in my project.

Alezis
  • 2,659
  • 3
  • 27
  • 34
2

1st of all i understand the idea behind your thoughts, and as such i'll start to explain the "why" are you not able to create multiple users with the same name.

The username with the same name: The problem you encounter right now is related to the IdentityDbContext. As you can see (https://aspnetidentity.codeplex.com/SourceControl/latest#src/Microsoft.AspNet.Identity.EntityFramework/IdentityDbContext.cs), the identityDbContext sets up rules about the unique users and roles, First on model creation:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        if (modelBuilder == null)
        {
            throw new ArgumentNullException("modelBuilder");
        }

        // Needed to ensure subclasses share the same table
        var user = modelBuilder.Entity<TUser>()
            .ToTable("AspNetUsers");
        user.HasMany(u => u.Roles).WithRequired().HasForeignKey(ur => ur.UserId);
        user.HasMany(u => u.Claims).WithRequired().HasForeignKey(uc => uc.UserId);
        user.HasMany(u => u.Logins).WithRequired().HasForeignKey(ul => ul.UserId);
        user.Property(u => u.UserName)
            .IsRequired()
            .HasMaxLength(256)
            .HasColumnAnnotation("Index", new IndexAnnotation(new IndexAttribute("UserNameIndex") { IsUnique = true }));

        // CONSIDER: u.Email is Required if set on options?
        user.Property(u => u.Email).HasMaxLength(256);

        modelBuilder.Entity<TUserRole>()
            .HasKey(r => new { r.UserId, r.RoleId })
            .ToTable("AspNetUserRoles");

        modelBuilder.Entity<TUserLogin>()
            .HasKey(l => new { l.LoginProvider, l.ProviderKey, l.UserId })
            .ToTable("AspNetUserLogins");

        modelBuilder.Entity<TUserClaim>()
            .ToTable("AspNetUserClaims");

        var role = modelBuilder.Entity<TRole>()
            .ToTable("AspNetRoles");
        role.Property(r => r.Name)
            .IsRequired()
            .HasMaxLength(256)
            .HasColumnAnnotation("Index", new IndexAnnotation(new IndexAttribute("RoleNameIndex") { IsUnique = true }));
        role.HasMany(r => r.Users).WithRequired().HasForeignKey(ur => ur.RoleId);
    }

secondly on validate entity:

protected override DbEntityValidationResult ValidateEntity(DbEntityEntry entityEntry,
        IDictionary<object, object> items)
    {
        if (entityEntry != null && entityEntry.State == EntityState.Added)
        {
            var errors = new List<DbValidationError>();
            var user = entityEntry.Entity as TUser;
            //check for uniqueness of user name and email
            if (user != null)
            {
                if (Users.Any(u => String.Equals(u.UserName, user.UserName)))
                {
                    errors.Add(new DbValidationError("User",
                        String.Format(CultureInfo.CurrentCulture, IdentityResources.DuplicateUserName, user.UserName)));
                }
                if (RequireUniqueEmail && Users.Any(u => String.Equals(u.Email, user.Email)))
                {
                    errors.Add(new DbValidationError("User",
                        String.Format(CultureInfo.CurrentCulture, IdentityResources.DuplicateEmail, user.Email)));
                }
            }
            else
            {
                var role = entityEntry.Entity as TRole;
                //check for uniqueness of role name
                if (role != null && Roles.Any(r => String.Equals(r.Name, role.Name)))
                {
                    errors.Add(new DbValidationError("Role",
                        String.Format(CultureInfo.CurrentCulture, IdentityResources.RoleAlreadyExists, role.Name)));
                }
            }
            if (errors.Any())
            {
                return new DbEntityValidationResult(entityEntry, errors);
            }
        }
        return base.ValidateEntity(entityEntry, items);
    }
}

The tip: What you can do to overcome this problem easilly, is, on the ApplicationDbContext that you currently have, override both these methods to overcome this validation

Warning Without that validation you now can use multiple users with the same name, but you have to implement rules that stop you from creating users in the same customer, with the same username. What you can do is, adding that to the validation.

Hope the help was valuable :) Cheers!

C-JARP
  • 1,028
  • 14
  • 16
  • Thanks for the answer, that was what I was looking for. Just one question: what should I change in the `OnModelCreating` method, because it's not clear what validation is set on that one. – Complexity Apr 14 '15 at 14:04
  • as you can see, on the table creation, username is being set as unique: ".HasColumnAnnotation("Index", new IndexAnnotation(new IndexAttribute("UserNameIndex") { IsUnique = true }));" You should remove this uniqueness ;) – C-JARP Apr 14 '15 at 14:06
  • Ok, and after digging some more, one more additional question: Also, the method `ValidateEntity` does call `base.ValidateEntity` which I cannot call, because that would result in calling the `IdentityContext` validation method, which will fail if I have multiple users. But not calling the base class will result in other properties not being validated, such as properties being marked with the `RequiredAttribute`. – Complexity Apr 14 '15 at 14:07
  • For the purpose at hand, since you are only dealing with that validation from identity2, you can replace that call to return new DbEntityValidationResult(entityEntry, new List()); – C-JARP Apr 14 '15 at 14:18
  • I don't quite understand your latest comment, do you mean to replace the base.Validate()? I don't think that would trigger the data annotations attribute. I have the feeling that there should be better solutions then adapting the code. – Complexity Apr 14 '15 at 14:21
  • I believe i did misunderstood what you said. Simply use your custom DbContext. As you can see, you have the source code of the identityDbContext, you can create your own DbContext, use what you want from the identityDbContext (copying it to your customDbContext) and you the can use the base.validation. It is not something hard to do, specially when you have the source code ;) – C-JARP Apr 14 '15 at 14:35
  • Thanks for your answer, but I don't believe that this is the correct solution for my problem. It might work, but I don't like the idea. – Complexity Apr 15 '15 at 06:38
  • Then you don't have any choice. The idea is not wrong, you are simply creating your custom identity dbconfig. By doing so, you are able to call base.validateentity, that is a must have. If you don't want to do that, you will simply have to choose another way. Identity 2 is customizable and it is working in my company 0 problems with a similar way of yours. You can allways run dbcontext instead of identitydbcontext, and override the onmodelcreation and validateentity. You wanted an answer, you have it and if you want identity, you will pass through this. Like it or not – C-JARP Apr 15 '15 at 08:21
  • I have the feeling that there is a lot more to do. I ended up a question on SO: http://stackoverflow.com/questions/20037145/how-to-implement-multi-tenant-user-login-using-asp-net-identity There's a nuget package of which I've checked the source and adapted it to meet my requirements. So, you've definitely point me in the right direction. Therefore I'm accepting this answer as accepted. Thanks – Complexity Apr 15 '15 at 08:26
  • Just to prove that they did the same thing, go to source and check their dbcontext https://github.com/JSkimming/AspNet.Identity.EntityFramework.Multitenant/blob/master/src/AspNet.Identity.EntityFramework.Multitenant/MultitenantIdentityDbContext.cs They created their own dbcontext, like i was telling you. I think that you misunderstood my intentions and guidance so, please study that solution, probably with the code review you will find your answers. Good luck mate, you are on the right path. – C-JARP Apr 15 '15 at 08:32
  • I know. I have checked the solution and I do understand what they are doing and also what you meant. All the pieces are coming together right now. That's the reason why I've not accepted your answer yesterday, because I wanted to double check the code which I'm implementing first. – Complexity Apr 15 '15 at 08:34