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
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.
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.
In light of the above, let’s define our account locking requirements:
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.
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:
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.
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:
User.getAuthenticated() accepts a username, a password, and a callback (cb)null is returned instead of the user, along with an appropriate enum valueWe’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:
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.