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 previous posts I showed how to implement a basic identity provider Web API using ASP.NET Core Identity.

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 utilise 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.

In a previous post I discussed how we used ASP.NET Identity and EF Core to customise the [AspNetUsers] table with additional fields 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. 

The authenticate() method within our userservice class is implemented as 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;
    }

    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