Home > Net >  Why do the ASP.NET Core password validation rules have no effect?
Why do the ASP.NET Core password validation rules have no effect?

Time:11-01

I'm building an ASP.NET Core 5 Web API using ASP.NET Core Identity on top a custom data access layer that makes use of the Dapper ORM. Fundamentally, things work as expected but I realized that the password validation rules provided by the Identity framework have no effect whatsoever and I fail to understand what is going on. Here is what I have:

First, because I rely on a custom data access layer I provide a custom implementation of Identity's IUserStore interface.

public class UserStore : IUserStore<AppUser>,
                         IUserPasswordStore<AppUser>,
                         IUserEmailStore<AppUser>
{
  private IRepository<AppUser> _repository;

  public UserStore(IConfiguration configuration)
  {
    _repository = new AppUserRepository(configuration.GetConnectionString("MyConnectionString"));
  }

  // IUserStore implementation
  // IUserPasswordStore implementation
  // IUserEmailStore implementation
}

Next, there is a binding model that is used to submit the information required to create new accounts.

public class RegisterBindingModel
{
  [Required]
  [Display(Name = "UserName")]
  public string UserName
  {
    get;
    set;
  }

  [Required]
  [DataType(DataType.Password)]
  [Display(Name = "Password")]
  public string Password
  {
    get;
    set;
  }

  [DataType(DataType.Password)]
  [Display(Name = "Confirm password")]
  [Compare(nameof(Password), ErrorMessage = "The password and confirmation password do not match.")]
  public string ConfirmPassword
  {
    get;
    set;
  }

  // remaining required properties
}

Next, new accounts are created via the AccountController:

[Authorize]
[ApiController]
[Route("api/Accounts")]
public class AccountController
{
  private readonly UserManager<AppUser>     _userManager;
  private readonly IPasswordHasher<AppUser> _passwordHasher;

  public AccountController(UserManager<AppUser> userManager, IPasswordHasher<AppUser> passwordHasher)
  {
    _userManager    = userManager;
    _passwordHasher = passwordHasher;
  }

  [AllowAnonymous]
  [HttpPost]
  [Route("Register")]
  public async Task<ActionResult> Register([FromBody]RegisterBindingModel model)
  {
    if(model == null)
    {
      return BadRequest();
    }

    if(!ModelState.IsValid)
    {
      return BadRequest(ModelState);
    }

    var user = new AppUser()
    {
      UserName  = model.UserName,
      Firstname = model.Firstname,
      Lastname  = model.Lastname,
      Email     = model.Email,
      Gender    = model.Gender
    };

    user.PasswordHash     = _passwordHasher.HashPassword(user, model.Password);
    IdentityResult result = await _userManager.CreateAsync(user);

    return GetHttpResponse(result);
  }

  [AllowAnonymous]
  [HttpPost]
  [Route("Token")]
  public async Task<IActionResult> Login([FromForm]LoginBindingModel model)
  {
    if(model == null)
    {
      return BadRequest();
    }

    if(!ModelState.IsValid)
    {
      return BadRequest(ModelState);
    }

    AppUser user = await _userManager.FindByNameAsync(model.UserName);

    if(user == null || !await _userManager.CheckPasswordAsync(user, model.Password))
    {
      return Unauthorized();
    }

    DateTime                currentTime     = DateTime.UtcNow;
    JwtSecurityTokenHandler jwtTokenHandler = new();
    SecurityTokenDescriptor tokenDescriptor = new()
    {
      Subject            = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.AppUserId.ToString()) }),
      IssuedAt           = currentTime,
      Expires            = currentTime.AddHours(_accessTokenValidityInHours),
      SigningCredentials = _signingCredentialsProvider.GetSigningCredentials()
    };

    return Ok(jwtTokenHandler.WriteToken(jwtTokenHandler.CreateToken(tokenDescriptor)));
  }

  ...
}

Finally, things are wired together as follows:

public class Startup
{
  public Startup(IConfiguration configuration)
  {
    Configuration = configuration;
  }

  public IConfiguration Configuration
  {
    get;
  }

  public void ConfigureServices(IServiceCollection services)
  {
    services.AddIdentityCore<AppUser>(options => Configuration.GetSection(nameof(IdentityOptions)).Bind(options));
    services.AddScoped<IPasswordHasher<AppUser>, Identity.PasswordHasher<AppUser>>();
    services.AddTransient<IUserStore<AppUser>, UserStore>();
    ...
  }
}

The corresponding settings are stored in the appsettings.json file:

{
  "IdentityOptions": {
    "Password": {
      "RequiredLength": 6,
      "RequiredUniqueChars": 6,
      "RequireNonAlphanumeric": true,
      "RequireUppercase": true,
      "RequireLowercase": true,
      "RequireDigit": true
    },
    "Lockout": {
      "AllowedForNewUsers": true,
      "MaxFailedAccessAttempts ": 5,
      "DefaultLockoutTimeSpan ": "00:05:00"
    }
  },
  ...
}

If I send an HTTP POST request with the necessary account data it literally doesn't matter what the password is. The call succeeds even if I just put 1 as a password which clearly violates the password rules. The statement if(!ModelState.IsValid) happily tells me that everything is fine with the model.

From what I see, ASP.NET Core Identity provides a PasswordValidator that apparently is supposed to validate the password according to the provided settings. That validator does not run in my setup, judging from the results that I get.

It is unclear to me whether things should just work they way they are or whether I need to implement something that I'm unaware of. Does anyone have more insight and can tell me what I'm missing here?

Edit:

I just realized that the default UserManager exposes a list of IPasswordValidator objects. Is the idea that I use that list to validate the password in my Register method of the AccountController?

CodePudding user response:

The reason your if(!ModelState.IsValid) does not see anything wrong comes from the fact that the Password parameter from your model RegisterBindingModel does not contain the same validation as your Options on your appsettings.json, you only verify that it is required (so one character is ok).

If you want to have the same validation you need to add more attributes on the Password parameter. I advise you to look this https://docs.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-5.0

CodePudding user response:

I ended up modifying the Register method as follows:

[AllowAnonymous]
[HttpPost]
[Route("Register")]
public async Task<ActionResult> Register([FromBody]RegisterBindingModel model)
{
  if(model == null)
  {
    return BadRequest();
  }

  if(!ModelState.IsValid)
  {
    return BadRequest(ModelState);
  }

  AppUser user = new()
  {
    UserName  = model.UserName,
    Firstname = model.Firstname,
    Lastname  = model.Lastname,
    Email     = model.Email,
    Gender    = model.Gender
  };

  IdentityResult result;

  foreach(IPasswordValidator<AppUser> passwordValidator in _userManager.PasswordValidators)
  {
    result = await passwordValidator.ValidateAsync(_userManager, user, model.Password);

    if(!result.Succeeded)
    {
      return BadRequest(result.Errors);
    }
  }

  user.PasswordHash = _userManager.PasswordHasher.HashPassword(user, model.Password);
  result            = await _userManager.CreateAsync(user);

  return GetHttpResponse(result);
}

The default UserManager contains the PasswordValidators property which allows me to access all PasswordValidators. I just loop through them and call the ValidateAsync method on the password submitted by the user.

  • Related