Application security
.NET .NET Core ASP.NET Core ASP.NET Core Identity Best Practices C# OWASP Security Visual Studio

How to Implement Account Lockouts in ASP.NET Core Identity

Welcome to today’s post.

In today’s post I will be showing how we can implement account lockouts in our identity SSO using ASP.NET Core Identity.

In a previous post I showed how to implement a basic identity provider Web API using ASP.NET Core Identity.

In this post I will be amending the authentication method in the identity service to lock out accounts.

When to Lockout a User Account

You will be aware that in almost all credential-based security systems that after you have made maximum number of attempts at trying to login to your account, you experience an account lockout.

Following an account lockout, you may receive a screen response and perhaps an email that tells you that your account is locked and should contact customer support to get your account released or reinstated.

This is common security practice in the industry to ensure that automated password bots or hackers do not apply iterative brute-force methods of attempted logins of accounts. The maximum invalid login limit is invoked to prevent a combinatorial attack on accounts, forcing the real account holder to have the account credentials validly reset.

In a previous post I discussed OWASP and how we can utilize these in our .NET Core applications.

The basis behind the brute-force attack control is to impose the invalid account login attempts.

I will now go over the basis of this implementation.

How User Account Lockouts function in ASP.NET Core Identity

In a previous post I discussed how we used ASP.NET Identity and EF Core to customize the [AspNetUsers] table with additional fields included in the ApplicationUser object for our user login data, There are three key fields that we use to control account lockouts, these are:

[AccessFailedCount],

[LockoutEnabled]

and

[LockoutEnd]

I will explain what the respective purpose of these fields are:

[AccessFailedCount]

This field is a running count of failed login attempts.

[LockoutEnabled]

If a flag that ensures the account can be locked out. If true, then the account can be locked out. If false, then the account cannot be locked out. It is suggested that with administrator accounts, the lockout is not enabled. 

[LockoutEnd]

When LockoutEnabled is true and AccessFailedCount exceeds the maximum login attempts, the LockoutEnd field defines the end date when the lockout for the account to expire. After this date, the account can be reinstated.   

In our identity server API, we have a method, authenticate(), that takes a user name and password as input. The response is the username and a JSON access token. 

Implementation of a User Account Lockouts in ASP.NET Core Identity

The authenticate() method within our userservice class implemented without a lockout functionality is shown:

public async Task<AuthenticateUserResponseModel> Authenticate(string username, 
string password)
{
    var responseStatus = new AuthenticateUserResponseModel();
    var responseErrors = new List<AuthenticationErrorResponseModel>();

    if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
    {
       	responseErrors.Add(new AuthenticationErrorResponseModel()
        {
            ErrorCode = Constants.EMPTY_USER_NAME_PWD,
            ErrorDescription = "The username or password is empty."
        });
        responseStatus.IsAuthenticated = false;
        responseStatus.errors = responseErrors;
        return responseStatus;
    }

    var user = _db.Users.SingleOrDefault(x => x.UserName == username);

    // check if username exists
    if (user == null)
    {
        _logger.LogError($"The user {username} does not exist.");
        _logger.LogInformation($"The user {username} does not exist.");
        responseErrors.Add(new AuthenticationErrorResponseModel()
        {
            ErrorCode = Constants.INVALID_USER_NAME,
            ErrorDescription = $"The user {username} does not exist."
        });
        responseStatus.IsAuthenticated = false;
        responseStatus.errors = responseErrors;
        return responseStatus;
    }

    // check if password is correct          
    PasswordHasher<ApplicationUser> passwordHasher = new 
    PasswordHasher<ApplicationUser>();
    if (passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password) 
       	== PasswordVerificationResult.Failed)
    {
       	_logger.LogInformation("The user password is invalid.");
        responseErrors.Add(new AuthenticationErrorResponseModel()
        {
            ErrorCode = Constants.INVALID_USER_PASSWORD_CODE,
            ErrorDescription = "The user password is invalid."
        });
    }

    _logger.LogInformation($"The user {username} has been successfully authenticated.");

    responseStatus.IsAuthenticated = true;

    // authentication successful
    return responseStatus;
}

Before we check the user password, we verify that the user account is not locked out. We do this by checking the LockoutEnabled field has a value and is true. This is done with the code excerpt below:

if (user.LockoutEnabled && user.LockoutEnd.HasValue)
{
      _logger.LogInformation($"The user account {username} is locked out.");
responseErrors.Add(new AuthenticationErrorResponseModel()
      {
            ErrorCode = Constants.USER_ACCOUNT_IS_LOCKED,
            ErrorDescription = "The user account is locked."
      });
      responseStatus.IsAuthenticated = false;
      responseStatus.errors = responseErrors;
      return responseStatus;
}

When a lockout is detected, the error code and description is added to the responseErrors list, which is then set to the errors list property within the responseStatus object.

The next check for exceeding the maximum number of retries and either locking out the account or increasing the number of retries is shown below:

// Bump up the access failed attempts for the user.
var rslt = await _userManager.AccessFailedAsync(user); 
if (rslt.Succeeded)
{
      if (user.AccessFailedCount > Constants.MAXIMUM_LOGIN_ATTEMPTS)
      {
            responseErrors.Add(new AuthenticationErrorResponseModel()
            {
       		      ErrorCode = Constants.USER_ACCOUNT_LOCKED,
       		      ErrorDescription = "The user account has been locked."
 		    });
            user.LockoutEnabled = true;
            user.LockoutEnd = DateTime.Now.AddYears(200);
      }
      responseStatus.IsAuthenticated = false;
      responseStatus.errors = responseErrors;
      return responseStatus;
}

The password attempts count is stored in the AccessFailedCount field. When the value is exceeded, the LockoutEnabled field is set to true, and the lockout duration is increased by 200 years. As we did for the lockout field detection, we add the error code and description to the responseStatus object.

After adding the above pieces of logic to the original method, we get the following amended authentication with account lockout logic:

public async Task<AuthenticateUserResponseModel> Authenticate(string username, 
string password)
{
    var responseStatus = new AuthenticateUserResponseModel();
    var responseErrors = new List<AuthenticationErrorResponseModel>();

    if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
    {
       	responseErrors.Add(new AuthenticationErrorResponseModel()
        {
            ErrorCode = Constants.EMPTY_USER_NAME_PWD,
            ErrorDescription = "The username or password is empty."
        });
        responseStatus.IsAuthenticated = false;
        responseStatus.errors = responseErrors;
        return responseStatus;
    }

    var user = _db.Users.SingleOrDefault(x => x.UserName == username);

    // check if username exists
    if (user == null)
    {
        _logger.LogError($"The user {username} does not exist.");
        _logger.LogInformation($"The user {username} does not exist.");
        responseErrors.Add(new AuthenticationErrorResponseModel()
        {
            ErrorCode = Constants.INVALID_USER_NAME,
            ErrorDescription = $"The user {username} does not exist."
        });
        responseStatus.IsAuthenticated = false;
        responseStatus.errors = responseErrors;
        return responseStatus;
    }

    if (user.LockoutEnabled && user.LockoutEnd.HasValue)
    {
       	_logger.LogInformation($"The user account {username} is locked out.");
        responseErrors.Add(new AuthenticationErrorResponseModel()
        {
            ErrorCode = Constants.USER_ACCOUNT_IS_LOCKED,
            ErrorDescription = "The user account is locked."
        });
        responseStatus.IsAuthenticated = false;
        responseStatus.errors = responseErrors;
        return responseStatus;
    }

    // check if password is correct          
    PasswordHasher<ApplicationUser> passwordHasher = new 
    PasswordHasher<ApplicationUser>();
    if (passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password) 
       	== PasswordVerificationResult.Failed)
    {
       	_logger.LogInformation("The user password is invalid.");
        responseErrors.Add(new AuthenticationErrorResponseModel()
        {
            ErrorCode = Constants.INVALID_USER_PASSWORD_CODE,
            ErrorDescription = "The user password is invalid."
        });

        // Bump up the access failed attempts for the user.
        var rslt = await _userManager.AccessFailedAsync(user); 
        if (rslt.Succeeded)
        {
            if (user.AccessFailedCount > Constants.MAXIMUM_LOGIN_ATTEMPTS)
            {
                responseErrors.Add(new AuthenticationErrorResponseModel()
                {
                    ErrorCode = Constants.USER_ACCOUNT_LOCKED,
                    ErrorDescription = "The user account has been locked."
                });
                user.LockoutEnabled = true;
               	user.LockoutEnd = DateTime.Now.AddYears(200);
            }
            responseStatus.IsAuthenticated = false;
            responseStatus.errors = responseErrors;
            return responseStatus;
        }
    }

    _logger.LogInformation($"The user {username} has been successfully authenticated.");

    responseStatus.IsAuthenticated = true;

    // authentication successful
    return responseStatus;
}

The authentication code does the following:

  1. Checks the username and password are entered (not null).
  2. Checks the username is valid (it exists in the user account table).
  3. Checks if lockout is enabled for the account and the lockout end is set to an actual date.
  4. Checks if the current account’s password hash matches the hash of the entered input password.
  5. If the passwords do not match, then increase the access failed count and set the lockout end to the lockout expiry date.

When the password hash for the current account and the entered password hash fails to match as shown with the following:

if (passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password) 
      == PasswordVerificationResult.Failed)

Then the access failed fields are incremented with the following user manager command:

await _userManager.AccessFailedAsync(user);

Before we attempt any authentication, the user account table will look like the following:

Notice that initially the AccessFailedCount field is zero, LockoutEnabled is 1, and LockoutEnd is null (uninitialized).

Note that after running the above access failure command, the field AccessFailedCount will be incremented by one as shown:

Note: The AccessFailedAsync() command also commits the account record change to the database. There is no need to call UpdateAsync(user) as this will repeat the increment again!

When the failed access limit is reached we set the LockoutEnd date as shown:

if (user.AccessFailedCount > Constants.MAXIMUM_LOGIN_ATTEMPTS)
{
    …
    user.LockoutEnd = DateTime.Now.AddYears(200);
    …

The resulting record update is shown with the LockoutEnd set to 200 years from today:

The controller that processes the result of the authentication is shown:

[AllowAnonymous]
[HttpPost("api/[controller]/authenticate")]
public async Task<IActionResult> Authenticate([FromBody]UserViewModel userDto)
{
    try
    {
        var response = await _userService.Authenticate(userDto.UserName, userDto.Password);
        bool isAuthenticated = response.IsAuthenticated;

        if (!isAuthenticated)
        {
            StringBuilder messageString = new StringBuilder();
            foreach (AuthenticationErrorResponseModel error in response.errors)
                messageString.AppendLine(error.ErrorDescription);
            return BadRequest(new { message = messageString.ToString() });
        }

        string email = _userService.GetEmail(userDto.UserName);

        string tokenString = _tokenManager.GenerateToken(userDto.UserName, email);

        // return basic user info (without password) and token to store client side
        return Ok(new
        {
            Username = userDto.UserName,
            Token = tokenString
        });
    }
    catch (Exception ex)
    {
        return BadRequest(new { message = ex.Message.ToString() });
    }
}

As I have demonstrated in the explanation and implementation, an account lockout should be part of a suite of tools that cover security best practices used in all applications whose goal is securing password protected accounts, protecting them from multiple brute-force access attempts.

That’s all for today’s post.

I hope you found this post useful and informative.

Social media & sharing icons powered by UltimatelySocial