Search code examples
c#asp.net-coreasp.net-core-identity

IUserValidator for IdentityUser


I have custom user email validator for Identity user manager.

It was created for validation user Email based on additional property IsDeleted(new users can be added with email of deleted user).

This is how it looks.

public class UserEmailValidator<TUser> : IUserValidator<TUser>
    where TUser : UserAuth
{
    private readonly IUnitOfWork _unitOfWork;

    public UserEmailValidator(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<IdentityResult> ValidateAsync(UserManager<TUser> manager, TUser user)
    {
        var errors = new List<IdentityError>();

        var existingAccount = await _unitOfWork.AuthUsers.Get()
            .FirstOrDefaultAsync(u => u.NormalizedEmail == user.Email.ToUpper() && !u.IsDeleted);
        if (existingAccount != null)
            errors.Add(new IdentityError() { Code = GlobalData.Translations.IdentityKeys.DuplicateEmail });

        return errors.Any()
            ? IdentityResult.Failed(errors.ToArray())
            : IdentityResult.Success;
    }
}

When I use method var res1 = await userManager.CreateAsync(newUser) it works as expected. The value of res1 is Succeed.

But actually ValidateAsycn method calls as well when user is assigning to role

var res2 = await userManager.AddToRoleAsync(newUser, "admin") 

and the result of res2 is Failed, because of duplicated email. Is there a way to clarify what the action is going to be validated(user creation, adding to role or any other type of action), to provide correct validation for all cases?

Here is the setup from Startup.cs

var userBuilder = services.AddIdentity<UserAuth, Role>(options =>
{
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
    options.Lockout.MaxFailedAccessAttempts = 10;
    options.Lockout.AllowedForNewUsers = true;

    options.ClaimsIdentity.UserIdClaimType = GlobalData.CustomClaimNames.UserId;
}).AddEntityFrameworkStores<ApplicationDbContext>()
.AddUserValidator<UserEmailValidator<UserAuth>>()
.AddDefaultTokenProviders()
.AddEmailAndPasswordConfimationTotpTokenProvider();

UPD:

await _userManager.UpdateAsync(user);
await _userManager.RemovePasswordAsync(user);

as well call same ValidateAsync method


Solution

  • Is there a way to clarify what the action is going to be validated...?

    There's no context for a call to ValidateUser, except for the User itself, which means there's no real way to know exactly why it's being invoked.

    UserManager contains a protected method (ValidateAsync) that is called internally whenever UserManager.CreateAsync or UserManager.UpdateUserAsync is called. A call to AddToRoleAsync results in a call to UpdateUserAsync, which ends up with ValidateAsync running through the implementations of IUserValidator<TUser> to perform the validation.

    The implementation of the built-in UserValidator<TUser> that you've replaced handles your problem with the following check (source):

    var owner = await manager.FindByEmailAsync(email);
    if (owner != null && 
        !string.Equals(await manager.GetUserIdAsync(owner), await manager.GetUserIdAsync(user)))
    {
        errors.Add(Describer.DuplicateEmail(email));
    }
    

    First, it checks whether or not there's an existing account with that email address and then checks whether or not that account is the same as the one being validated. If it's the same, it's not a duplicate.

    This all means that you should be able to extend your check to perform similar logic. For example:

    var existingAccount = await _unitOfWork.AuthUsers.Get().FirstOrDefaultAsync(u =>
        u.NormalizedEmail == user.Email.ToUpper() &&
        !u.IsDeleted &&
        u.Id != user.Id);
    

    Here, I've added u.Id != user.Id, which is a lot less involved than the built-in implementation as your code knows it's working with UserAuth and can use its properties directly.