User Account Lockout

User Account Lockout

tutorials

The Curity Identity Server provides a username/password (HTML Form) authenticator out-of-the-box that can lock a user based on a configured number of failed authentication attempts. This can be coupled with the rolling-session-period setting to keep the user locked for a configured number of minutes. The lockout is session-based, and the number of failed login attempts is stored in a user session.

Sometimes, however, it’s desirable to control the lockout centrally to not rely on the user session itself; and with that, lock the user across any session. This article outlines a way to configure a centralized lockout of a user with event listeners. This can be accomplished using either a bucket or the default accounts table, and a custom SQL query used by the Credential Manager in use.

Overview

The solution will leverage two Event Listeners, one for FailedVerificationCredentialManagerEvent and one for SuccessfulVerificationCredentialManagerEvent.

When the FailedVerificationCredentialManagerEvent event triggers, a counter tied to the user is incremented and persisted in a data store. When the counter reaches a configurable threshold (maxLogin), a timestamp value of current time + a configurable lockout time is stored alongside the counter. The timestamp is in UNIX time format and represents the time the user is locked until. For every subsequent failed login attempt, the timestamp will be updated.

If a SuccessfulVerificationCredentialManagerEvent is triggered, the timestamp will be set to 0, and the failed login counter will be set to 0, effectively resetting the lockout. This event only triggers if the user credentials are correct and the stored lockout timer has passed.

The Credential Query in the Data Source used by the Credential Manager needs to be updated to also read the timestamp. The query will compare the timestamp and current time and will not produce a result if the account is locked. When no result is found, the authentication fails.

Buckets or Accounts

Depending on the Curity Identity Server deployment, the lockout counter and time stamp could be stored in different data sources.

If the default Curity Identity Server database schema is used along with the accounts table to hold account information, the lockout counter and time stamp can be stored there. This will allow for a more optimized credential query since SQL JOIN can be avoided.

If the default accounts table is not used, it is possible to store this information in the buckets table instead. However, this forces the credential query to perform a SQL JOIN operation, which could negatively impact performance.

  1. Configure a new event listener, System -> Event Listeners -> New Event Listener, and name it account-lockout. Select the Script Event Listener as the type.
  2. Enable Bucket and choose a Data Source that has a buckets table. (The buckets table is a part of the default Curity schema).
  3. Next to Procedure, click New. Give the Procedure a name, account-lockout-procedure, and click Create.
  4. In the JavaScript editor that pops up, replace all the code with the following:
var listeners = {
    
    'FailedVerificationCredentialManagerEvent': function (context, event) {
        var maxLogin = 3;  //Number of attempts before user is locked out
        var lockoutTime = 60; //In seconds
        var incrementedCounter;
        
        var subject = event.getSubject();
        logger.debug("Failed login by user {}.", subject);
        
        var bucket = context.getBucket();
        var bucketAttributes = bucket.getAttributes(subject, "user_auth_counter");
        logger.debug("Attributes retrieved from bucket for user {}: {}", subject, bucketAttributes);
        
        //No value in buckets
        if (bucketAttributes == "{}"){
            incrementedCounter = 1;
        }
        else{
            incrementedCounter = parseInt(bucketAttributes.get("failedAuthenticationAttempts")) + 1;    
        }
        
        if(incrementedCounter < maxLogin)
        {
            bucket.storeAttributes(subject, "user_auth_counter", {
                failedAuthenticationAttempts:incrementedCounter,
                lockoutUntilTime:0
            });
        }
        else
        {
            var currentTime = Math.round(new Date().getTime() / 1000);
            var lockoutUntilTime = (currentTime + lockoutTime);  //Add how long the user should be locked out to the current time
            
            bucket.storeAttributes(subject, "user_auth_counter", {
                failedAuthenticationAttempts: incrementedCounter, 
                lockoutUntilTime: lockoutUntilTime.toString(), 
            });
        }
    },
    'SuccessfulVerificationCredentialManagerEvent': function (context, event) {
        var subject = event.getSubject();
        logger.debug("Successful login by user {}. Resetting login counter in bucket.", subject);
        var bucket = context.getBucket();
        bucket.storeAttributes(subject, "user_auth_counter", {
            failedAuthenticationAttempts:0,
            lockoutUntilTime:0
        });
    }
};

Configure parameters

Make sure to configure the maxLogin and lockoutTime variables in the script to match the requirements.

Credential Query

The Credential Query configured on the Data Source (used by the Credential Manager and Authenticator) must be updated. Do so here: Facilities -> Data Sources -> <data source used> -> Credential Query.

IF (SELECT JSON_VALUE(buckets.attributes, '$.lockoutUntilTime') FROM buckets WHERE buckets.subject = :subjectId) IS NULL
    SELECT [User].UserID,
           [User].username AS userName,
           [User].password
    FROM [User] WHERE [User].username=:subjectId;
ELSE
    SELECT
        [User].UserID,
        [User].username AS userName,
        [User].password
    FROM [User] JOIN buckets ON
            [User].username = buckets.subject WHERE
            [User].username=:subjectId AND
        ( (SELECT JSON_VALUE(buckets.attributes, '$.lockoutUntilTime')) <
          ( SELECT DATEDIFF_BIG(second, '1970-01-01 00:00:00', GETUTCDATE())) );
  1. Configure a new event listener, System -> Event Listeners -> New Event Listener, and name it account-lockout. Select the Script Event Listener as the type.
  2. Enable the Account Manager and choose the Account Manager used by the Authenticator.
  3. Next to Procedure, click New. Give the Procedure a name, account-lockout-procedure, and click Create.
  4. In the JavaScript editor that pops up, replace all the code with the following:
var listeners = {
    
    'FailedVerificationCredentialManagerEvent': function (context, event) {
        var maxLogin = 3;  //Number of attempts before user is locked out
        var lockoutTime = 60; //In seconds
        var incrementedCounter;
        
        var subject = event.getSubject();
        logger.debug("Failed login by user {}.", subject);
        
        var am = context.getAccountManager();
        var account = am.getByUserName(subject);
        
        logger.debug("Attributes retrieved from accounts for user {}: {}", subject, account);
         
        //No value found for failedAuthenticationAttempts
        if (account.failedAuthenticationAttempts === null)
        {
            incrementedCounter = 1;
        }
        else
        {
            incrementedCounter = parseInt(account.failedAuthenticationAttempts) + 1;    
        }
        
        if(incrementedCounter < maxLogin)
        {
           account.failedAuthenticationAttempts = incrementedCounter;
           account.lockoutUntilTime = 0;
           am.updateAccount(account);
        }
        else
        {
            var currentTime = Math.round(new Date().getTime() / 1000);
            var lockoutUntilTime = (currentTime + lockoutTime);  //Add how long the user should be locked out to the current time
            
           account.failedAuthenticationAttempts = incrementedCounter;
           account.lockoutUntilTime = lockoutUntilTime.toString();
           am.updateAccount(account);
        }
    },
    'SuccessfulVerificationCredentialManagerEvent': function (context, event) {
        var subject = event.getSubject();
        logger.debug("Successful login by user {}. Resetting login counter in accounts table.", subject);
        var am = context.getAccountManager();
        var account = am.getByUserName(subject);
        account.failedAuthenticationAttempts = 0;
        account.lockoutUntilTime = 0;
        am.updateAccount(account);
    }
};

Configure parameters

Ensure to configure the maxLogin and lockoutTime variables in the script to match the requirements.

Credential Query

The Credential Query configured on the Data Source (used by the Credential Manager that the Authenticator is using) must be updated. Do so here:Facilities -> Data Sources -> <data source used> -> Credential Query.

IF (SELECT JSON_VALUE(attributes, '$.lockoutUntilTime') FROM accounts WHERE accounts.username = :subjectId) IS NULL
    SELECT account_id,
           username AS userName,
           password
    FROM accounts WHERE username=:subjectId;
ELSE
    SELECT
        account_id,
        username AS userName,
        password
    FROM accounts WHERE
            username=:subjectId AND
        ( (SELECT JSON_VALUE(attributes, '$.lockoutUntilTime')) <
          ( SELECT DATEDIFF_BIG(second, '1970-01-01 00:00:00', GETUTCDATE())) );

MS SQL specific

The SQL query used in both solutions is specific to MS SQL and is tested with version 2017-CU24-ubuntu-16.04. But, it should work with other versions of MS SQL as well.

It would also be possible to achieve the same approach using different databases, as long as they enable similar functions to read the JSON blob and can handle IF/ELSE statements in the query.

Optimization

The complexity of the SQL query could have an impact on performance due to using the JSON_VALUE function. To improve performance related to the JSON_VALUE function, read this post: Query performance for JSON objects inside SQL Server using JSON_VALUE function - Blog IT.

Keep up with our latest articles and how-tos RSS feeds.