Password Authentication with Mongoose (Part 2): Account Locking

Oct 24 • Posted 1 year ago

This post is Part 2 (of 2) on implementing secure username/password authentication for your Mongoose User models. In Part 1 we implemented one-way password encryption and verification using bcrypt. Here in Part 2 we’ll discuss how to prevent brute-force attacks by enforcing a maximum number of failed login attempts. This was originally posted on the DevSmash Blog 

Quick Review

If you haven’t done so already, I recommend you start with reading Part 1. However, if you’re like me and usually gloss over the paragraph text looking for code, here’s what our User model looked like when we left off:

As can be seen, there’s not much too it - we hash passwords before documents are saved to MongoDB, and we provide a basic convenience method for comparing passwords later on.

Why do we Need Account Locking?

While our code from Part 1 is functional, it can definitely be improved upon. Hashing passwords will save your bacon if a hacker gains access to your database, but it does nothing to prevent brute-force attacks against your site’s login form. This is where account locking comes in: after a specific number of failed login attempts, we simply ignore subsequent attempts, thereby putting the kibosh on the brute-force attack.

Unfortunately, this still isn’t perfect. As stated by OWASP:

Password lockout mechanisms have a logical weakness. An attacker that undertakes a large numbers of authentication attempts on known account names can produce a result that locks out entire blocks of application users accounts.

The prescribed solution, then, is to continue to lock accounts when a likely attack is encountered, but then unlock the account after some time has passed. Given that a sensible password policy puts the password search space into the hundreds of trillions (or better), we don’t need to be too worried about allowing another five guesses every couple of hours or so.

Requirements

In light of the above, let’s define our account locking requirements:

  1. A user’s account should be “locked” after some number of consecutive failed login attempts
  2. A user’s account should become unlocked once a sufficient amount of time has passed
  3. The User model should expose the reason for a failed login attempt to the application (though not necessarily to the end user)

Step 1: Keeping Track of Failed Login Attempts and Account Locks

In order to satisfy our first and second requirements, we’ll need a way to keep track of failed login attempts and, if necessary, how long an account is locked for. An easy solution for this is to add a couple properties to our User model:

loginAttempts will store how many consecutive failures we have seen, and lockUntil will store a timestamp indicating when we may stop ignoring login attempts.

Step 2: Defining Failed Login Reasons

In order to satisfy our third requirement, we’ll need some way to represent why a login attempt has failed. Our User model only has three reasons it needs to keep track of:

  1. The specified user was not found in the database
  2. The provided password was incorrect
  3. The maximum number of login attempts has been exceeded

Any other reason for a failed login will simply be an error scenario. To describe these reasons, we’re going to kick it old school with a faux-enum:

Please note that it is almost always a bad idea to tell the end user why a login has failed. It may be acceptable to communicate that the account has been locked due to reason 3, but you should consider doing this via email if at all possible.

Step 3: Encapsulating the Login Process

Lastly, let’s make life easier on the consuming code base by encapsulating the whole login process. Given that our security requirements have become much more sophisticated, we’ll allow external code to interact through a single User.getAuthenticated() static method. This method will operate as follows:

  1. User.getAuthenticated() accepts a username, a password, and a callback (cb)
  2. If the provided credentials are valid, then the matching user is passed to the callback
  3. If the provided credentials are invalid (or maximum login attempts has been reached), then null is returned instead of the user, along with an appropriate enum value
  4. If an error occurs anywhere in the process, we maintain the standard “errback” convention

We’ll also be adding a new helper method (user.incLoginAttempts()) and a virtual property (user.isLocked) to help us out internally.

Because our User model is starting to get somewhat large, I’m just going to jump straight to the end result with everything included:

Sample Usage

Assuming that you’ve saved the above code as user-model.js, here’s how you would go about using it:

Thanks for reading!

Jeremy Martin is the creator of (recently launched) DevSmash.com, a software developer and Open Source Evangelist at his day job, a Node.js contributor, MongoDB fan boy, and husband to the greatest gal on the planet. Online he goes by @jmar777.