1

I'm wondering how and what is the best way to lock user account after X times failed logins? I have table where I keep track of users failed login attempts. Table stores time stamp, username, ip address and browser type. After I detect incorrect login information, cfquery will pull records from failed login table based on username or IP address. If there is 5 or more invalid attempts I set account for inactive. Now I would like to somehow set timer that will start counting 5 minutes since last invalid attempt for that user. Then account should change the status to active. Here is my code that I have so far:

<cfquery name="checkUser" datasource="#dsn#">
    SELECT UserName, Password, Salt, LockedUntil
    FROM Users
    WHERE UserName = <cfqueryparam cfsqltype="cf_sql_varchar" value="#trim(FORM.username)#" maxlength="50">
       AND Active = 1
</cfquery>

<cfif len(checkUser.LockedUntil) AND dateCompare(now(), checkUser.LockedUntil,'n') EQ -1>
    <cfset fnResults.status = "400">
    <cfset fnResults.message = "This account is locked for 5 min.">
    <cfreturn fnResults>
    <cfabort>
</cfif>

<cfset storedPW = checkUser.Password>
<cfset enteredPW = FORM.password & checkUser.Salt>

<cfif checkUser.recordCount NEQ '1' OR (hash(enteredPW,"SHA-512") NEQ storedPW>
    <cfquery name="logFail" datasource="#dsn#">
        INSERT INTO FailedLogins(
           LoginTime,
           LoginUN,
           LoginIP,
           LoginBrowser
        )VALUES(
           CURRENT_TIMESTAMP,
           <cfqueryparam cfsqltype="cf_sql_varchar" value="#FORM.username#" maxlength="50">,
           <cfqueryparam cfsqltype="cf_sql_varchar" value="#REMOTE_ADDR#" maxlength="20">,
           <cfqueryparam cfsqltype="cf_sql_varchar" value="#CGI.HTTP_USER_AGENT#" maxlength="500">
        )
    </cfquery>

    <!--- Pull failed logins based on username or IP address. --->
    <cfquery name="failedAttempts" datasource="#dsn#">
        SELECT LoginTime
        FROM FailedLogins
        WHERE LoginUN = <cfqueryparam cfsqltype="cf_sql_varchar" value="#trim(FORM.username)#" maxlength="50">
            OR LoginIP = <cfqueryparam cfsqltype="cf_sql_varchar" value="#REMOTE_ADDR#" maxlength="20">
    </cfquery>

    <cfif failedAttempts.recordcount LT 4>
        <cfset fnResults.status = "400">
        <cfset fnResults.message = "Invalid Username or Password!">
    <cfelseif failedAttempts.recordcount EQ 4>
        <cfset fnResults.status = "400">
        <cfset fnResults.message = "This is your last attempt. If you fail to provide correct information account will be locked!">
    <cfelseif failedAttempts.recordcount GTE 5>
        <cfset lockUntil = DateAdd('n', 5, now())>
        <cfquery name="blockUser" datasource="#dsn#">
            UPDATE Users
            SET LockedUntil = <cfqueryparam cfsqltype="cf_sql_timestamp" value="#lockUntil#">
            WHERE UserName = <cfqueryparam cfsqltype="cf_sql_varchar" value="#trim(FORM.username)#" maxlength="50">
        </cfquery>

        <cfset fnResults.status = "400">
        <cfset fnResults.message = "This account is locked for 5 min.">
    </cfif>
<cfelse>
   //Clear failed login attempts
   //Update lockedUntil field to NULL
   //User logged in authentication successful!
</cfif>

After account is set to inactive / locked what would be the best way to set time count down and change the flag status? I saw some people recommended SQL Job but I'm not sure how often job should run and how to create that statement? If anyone can provide some example please let me know. Thank you.

espresso_coffee
  • 5,980
  • 11
  • 83
  • 193
  • *recommended SQL Job but I'm not sure...how to create that statement* Please don't take this the wrong way, but have you looked in your dbms documentation and actually tried the examples? A search on "sql server scheduled job" returns a ton of results and examples. https://stackoverflow.com/questions/5471080/how-to-schedule-a-job-for-sql-query-to-run-daily . – SOS Mar 19 '18 at 19:55
  • @Ageax I have checked and yes, there is a lot of examples but not for this particular case where I have to lock records for specific period of time. Also I'm not sure if this is the most efficient way to do this since that SQL Job should be running all the time pretty much and keep checking against failed attempts record. – espresso_coffee Mar 19 '18 at 19:58
  • 1
    Don't create a scheduled job. That is not necessary and as such will add unneeded processing to your db. Look at the answer that @David-Faber provided. Just add an additional check to your query to see if the current login attempt is beyond the lockout period. – Miguel-F Mar 19 '18 at 20:01
  • @Miguel-F I think espresso would still need a scheduled job for cleanup. A simple check and handling would be very light on the SQL server, and would keep the database from filling up with excess info. – Shawn Mar 19 '18 at 20:16
  • 1
    @Shawn Or you do the cleanup when/if a successful login attempt is made. – Miguel-F Mar 19 '18 at 20:24
  • Also, I'm going to point out one of my pet peeves: `NEQ '1'`. Please don't force CF to typecast a `1` from a text datatype back into a boolean. It's tiny, but still unnecessary and easily fixed on the front. Go with either `NOT checkUser.recordCount` or `checkUser NEQ 1` instead of `checkUser.recordCount NEQ '1'`. – Shawn Mar 19 '18 at 20:24
  • @Miguel-F Both. One is for login reasons and the other is for housekeeping. – Shawn Mar 19 '18 at 20:25
  • 1
    What I meant was, do the database clean up after the user has successfully entered login credentials (instead of a scheduled job). Only for that user. Doesn't matter, Either way will work. – Miguel-F Mar 19 '18 at 20:26
  • Also, look out for using `FORM.password`. Sanitize that string before you use it. – Shawn Mar 19 '18 at 20:31
  • @Miguel-F Yes, but also do cleanup on a periodic basis. If a user abandons a failed login without ever entering a successful login, you'd still want to cleanup the old records to keep your table from growing out of control. – Shawn Mar 19 '18 at 20:32
  • @Shawn By sanitizing `FORM.username` you are referring to `trim()` the value or check for special characters? They can't create user name here, that is something that is created for them and they can update later upon account is created. – espresso_coffee Mar 19 '18 at 20:34
  • @espresso_coffee - Doubtful you'd find a copy paste example. *"lock records for specific period of time"* So? :-) (Ignoring the question of whether you should use it... ) I don't think you're understanding how a scheduled job might work. It's designed to run every X intervals. So say a job might "unlock any locked out users every 5 minutes". That's slightly different than doing it on demand or on request. Users would be unlocked *at the next interval* (may be more than 5 minutes) vs at the exact time of the http request. Not saying that's the best option, just explaining how it might work. – SOS Mar 19 '18 at 20:35
  • @Miguel-F I think that could be nightly process that way table will remain clean. – espresso_coffee Mar 19 '18 at 20:36
  • @espresso_coffee No. `trim()` doesn't do any sensitization. It just removes characters from the ends. Because it's from the `FORM` scope, it can be anything the application sends back. Look at canonicalization and other methods of treating untrusted input. – Shawn Mar 19 '18 at 20:39
  • Also, since you are storing a password, my obligatory https://www.owasp.org/index.php/Password_Storage_Cheat_Sheet. And check out the `iterations` attribute of CF's `hash()` if you stick with that. – Shawn Mar 19 '18 at 20:39
  • Iteration is good - the longer password encryption takes the less likely a dictionary attack will be successful. I was going to suggest hashing in the database but SQL Server's `HASHBYTES()` function doesn't appear to support iteration. – David Faber Mar 21 '18 at 12:48
  • @DavidFaber I have tried and locked_until seems like a good option. Only concern about that solution is what after time expires? If user reset the password they will successfully pass authentication and all failed attempts will be removed, but if they still enter incorrect password what then? Count will go up to 6 so I'm wondering what should be the next step in that case? – espresso_coffee Mar 21 '18 at 19:46
  • @espresso_coffee another failed attempt would mean the value of `locked_until` gets updated. – David Faber Mar 21 '18 at 19:55

2 Answers2

5

What you can do is add a condition to the checkUser query:

<cfquery name="checkUser" datasource="#dsn#">
    SELECT UserName, Password, Salt, Active
      FROM Users u
     WHERE UserName = <cfqueryparam cfsqltype="cf_sql_varchar" value="#trim(FORM.username)#" maxlength="50">
      -- AND Active = 1
      AND NOT EXISTS ( SELECT 1 FROM FailedLogins fl
                        WHERE fl.LoginUN = u.UserName
                          AND DATEDIFF('ss', fl.LoginTime, CURRENT_TIMESTAMP) >= 300 )
</cfquery>

I've used 300 seconds instead of 5 minutes since DATEDIFF(), I believe, returns an int. I apologize in advance if this isn't quite the ideal syntax for SQL Server (I don't often work with it).

Then, above, if Active is zero, you can then (assuming the password is correct) update it to a value of 1 and either delete the failed logins associated with that account or somehow mark them inactive so they don't count against the 5 failed logins any more.

Query edited at the suggestion of a commenter below: (good suggestion, by the way!)

<cfquery name="checkUser" datasource="#dsn#">
    SELECT UserName, Password, Salt, Active
      FROM Users u
     WHERE UserName = <cfqueryparam cfsqltype="cf_sql_varchar" value="#trim(FORM.username)#" maxlength="50">
      -- AND Active = 1
      AND NOT EXISTS ( SELECT 1 FROM FailedLogins fl
                        WHERE fl.LoginUN = u.UserName
                          AND fl.loginTime < DATEADD(second, -300, CURRENT_TIMESTAMP) )
</cfquery>
David Faber
  • 12,277
  • 2
  • 29
  • 40
  • 3
    Small suggestion. Calculate the desired timestamp and create a comparison using `col >= {value}` or `col <= {value}`. It's more performance and index friendly than dateDiff(). – SOS Mar 19 '18 at 20:08
  • Instead of `ss` in the `DATEDIFF()`, spell out `second`. The abbreviations make for less typing, but can lead to some unexpected results. – Shawn Mar 19 '18 at 20:09
  • While this will work very well for the logins, you still should run a SQL job to cleanup your `FailedLogins` table. Otherwise, if someone fails log in 5 times and then leaves, they're failed login will stay in your database until the next time they log in, potentially forever. When logging things, cleanup can be very important. – Shawn Mar 19 '18 at 20:13
  • I'm not sure that I want to remove Active flag from my query. That part is little confusing why it's commented out? – espresso_coffee Mar 19 '18 at 20:25
  • `their` not `they're` <<<< Oops. – Shawn Mar 19 '18 at 20:45
  • @espresso_coffee I commented out the condition and returned the value, that way you can decide in code what do to based upon its value - if it is 1, process the login normally; if it is 0, re-activate the user. – David Faber Mar 19 '18 at 21:15
  • @Shawn, I would just do a cleanup upon successful re-activation or login. – David Faber Mar 21 '18 at 12:39
  • @DavidFaber That leaves the opportunity for old failed logins to accumulate in your database. Run a cleanup maybe once a day or every few hours. It doesn't have to be often, but often enough to keep your log table from blowing up. A SQL job like that would be very cheap. – Shawn Mar 21 '18 at 13:25
  • @Shawn I have updated my question with the solution on how to lock users account. I have used the logic for the accepted answer below. So far seems to work fine. After 5 failed login attempts I update LockedUntil filed and send the message to the user. Every tie user hit submit button first thing is to check for lockedUntil field. Using this solution I'm not sure there is a need for nightly clean up or SQL job. As soon as user provides correct information all failed logins will be removed. – espresso_coffee Mar 22 '18 at 13:50
  • @espresso_coffee My point about the nightly cleanup job wasn't to reset locked users. It was primarily to keep your database from blowing up when you have users who never successfully log in after a failure. Those users would just continue to pollute your db and could potentially run you out of space. – Shawn Mar 22 '18 at 13:57
  • Any type of logging should include either archiving or cleanup. Logs can get very large after a while. – Shawn Mar 22 '18 at 13:58
  • @Shawn So what is your suggestions on what failed logins should be cleared out every night, any login in the table or maybe based on the date/time? Also did you have a chance to look over my solution? – espresso_coffee Mar 22 '18 at 14:00
  • I would say anything older than your timeout. Or you could go back to any for the previous day. Or really any amount of time. The main goal is to purge old, irrelevant records. Granted, purging records daily would not lock out someone who enters 1 wrong password for 5 days in a row, but do you really care? – Shawn Mar 22 '18 at 15:17
  • @Shawn I don't think that clearing users/IP with only one failed attempt would be the problem. At the same time hackers can set the code to send only 3 or 4 attempts per day, so if I delete records with nightly process I wouldn't be able to check these failed logins. – espresso_coffee Mar 22 '18 at 15:22
  • Again, if a hacker is attempting 3-4 attempts per day, do you care? It would take so long for them to make a meaningful attack, that it would be impractical for them. The bigger likelihood would be that someone does a massive attack and dumps a ton of failed logs. Eventually they could DOS your entire application because it can no longer write to your database. Again, this is something you'll have to weigh within your system expectations. – Shawn Mar 22 '18 at 15:39
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/167350/discussion-between-espresso-coffee-and-shawn). – espresso_coffee Mar 22 '18 at 15:39
2

I think you'd have better luck with reversing your logic. Instead of having a column status with values Active or Inactive, consider having a column locked_until time instead.

Initially the locked_until value for a new user will be NULL (or 0) meaning it is not locked.

When there is a series of failed logins, set this to be the current time + 5 minutes.

For all actions for this user, check if current time is > locked_until value. If not, the account is still inactivate (locked).

Edit: I decided to write out some code because I forgot to account for users successfully logging in. Please see below; I'm not sure what language the original question is in but this answer is pseudo-python.

Assuming we have a database table similar to the following (ignoring salts etc..)

CREATE TABLE Users (
    UserName TEXT PRIMARY KEY,
    Password TEXT NOT NULL,
    LockUntil TIMESTAMP,
    FailedLogins INT DEFAULT 0
);

The login checking function is something like the following. Key points are:

  • Successful login clears sets FailedLogins to 0.
  • Set FailedLogins to 5 (along with LockUntil) when locking account.
  • A new failed login where FailedLogins=5 is an attempt for a newly unlocked account. (i.e. The account was implicitly unlocked and user is trying again).
def try_login(username, password):
    row = execute("SELECT Password,LockUntil,FailedLogins FROM Users WHERE UserName=?", username);
    if row is None:
        print("Unknown username")
        return False

    if row.LockUntil is not None and current_time() < row.LockUntil:
        print("Account locked. Try again later.")
        return False

    if password == row.Password:
        print('Successful login')
        execute("UPDATE Users SET LockUntil=NULL, FailedLogins=0 WHERE UserName=?", username)
        return True

    if row.FailedLogins == 4:
        print("Too many failures; locking account for 5 mins")
        lock_until = current_time() + 300
        execute("UPDATE Users SET LockUntil=?,FailedLogins=5 WHERE UserName=?", lock_until, username)
        return False

    failures = row.FailedLogins + 1
    if failures == 6:
        # User had locked account, which is now unlocked again.
        # But they failed to login again, so this is failure 1.
        failures = 1
    execute("UPDATE Users SET FailedLogins=? WHERE UserName=?", failures, username)
    return False
Hitobat
  • 2,847
  • 1
  • 16
  • 12
  • Basically once user sends username and password I will pull `locked_until` from `checkUser` query. If that field is not NULL then check if current time is greater than `locked_until` if not proceed to the sign in process.? – espresso_coffee Mar 20 '18 at 13:19
  • I have tried and locked_until seems like a good option. Only concern about that solution is what after time expires? If user reset the password they will successfully pass authentication and all failed attempts will be removed, but if they still enter incorrect password what then? Count will go up to 6 so I'm wondering what should be the next step in that case? – espresso_coffee Mar 21 '18 at 19:46
  • The lock expires at the time you put inside "locked_until". The idea behind this method is you only need to change the row in DB when you realise login failed and account should be locked. There's no need to come back later to say the account is now active, simply the time is expired so the lock no longer applies. – Hitobat Mar 21 '18 at 19:50
  • My solution is slightly different but still should work the same. I have updated my question above. I have tested and works fine. If you see anything that can be improved or has better approach please let me know. I will accept your solution as correct answer. thank you. – espresso_coffee Mar 22 '18 at 13:39