• Download Gist
jmar777 created this gist . View gist @ ee81a74
password-authentication-with-mongoose-part-2-account-locking.md
295 
...                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               ... 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
@@ -0,0 +1,295 @@
+*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](http://devsmash.com/blog/password-authentication-with-mongoose-and-bcrypt) using [bcrypt](http://en.wikipedia.org/wiki/Bcrypt). Here in Part 2 we'll discuss how to prevent brute-force attacks by enforcing a maximum number of failed login attempts.*
+
+## Quick Review
+
+If you haven't done so already, I recommend you start with reading [Part 1](http://devsmash.com/blog/password-authentication-with-mongoose-and-bcrypt). 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:
+
+```
+var mongoose = require('mongoose'),
+ Schema = mongoose.Schema,
+ bcrypt = require('bcrypt'),
+ SALT_WORK_FACTOR = 10;
+
+var UserSchema = new Schema({
+ username: { type: String, required: true, index: { unique: true } },
+ password: { type: String, required: true }
+});
+
+UserSchema.pre('save', function(next) {
+ var user = this;
+
+ // only hash the password if it has been modified (or is new)
+ if (!user.isModified('password')) return next();
+
+ // generate a salt
+ bcrypt.genSalt(SALT_WORK_FACTOR, function(err, salt) {
+ if (err) return next(err);
+
+ // hash the password using our new salt
+ bcrypt.hash(user.password, salt, function(err, hash) {
+ if (err) return next(err);
+
+ // override the cleartext password with the hashed one
+ user.password = hash;
+ next();
+ });
+ });
+});
+
+UserSchema.methods.comparePassword = function(candidatePassword, cb) {
+ bcrypt.compare(candidatePassword, this.password, function(err, isMatch) {
+ if (err) return cb(err);
+ cb(null, isMatch);
+ });
+};
+
+module.exports = mongoose.model('User', UserSchema);
+```
+
+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](https://www.owasp.org/index.php/Authentication_Cheat_Sheet#Implement_Account_Lockout):
+
+> 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:
+
+```
+var UserSchema = new Schema({
+ // existing properties
+ username: { type: String, required: true, index: { unique: true } },
+ password: { type: String, required: true },
+ // new properties
+ loginAttempts: { type: Number, required: true, default: 0 },
+ lockUntil: { type: Number }
+});
+```
+
+`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:
+
+```
+// expose enum on the model
+UserSchema.statics.failedLogin = {
+ NOT_FOUND: 0,
+ PASSWORD_INCORRECT: 1,
+ MAX_ATTEMPTS: 2
+};
+```
+
+*Please note that it is almost always a [bad idea to tell the end user why a login has failed](https://www.owasp.org/index.php/Authentication_Cheat_Sheet#Authentication_and_Error_Messages). 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:
+
+```
+var mongoose = require('mongoose'),
+ Schema = mongoose.Schema,
+ bcrypt = require('bcrypt'),
+ SALT_WORK_FACTOR = 10,
+ // these values can be whatever you want - we're defaulting to a
+ // max of 5 attempts, resulting in a 2 hour lock
+ MAX_LOGIN_ATTEMPTS = 5,
+ LOCK_TIME = 2 * 60 * 60 * 1000;
+
+var UserSchema = new Schema({
+ username: { type: String, required: true, index: { unique: true } },
+ password: { type: String, required: true },
+ loginAttempts: { type: Number, required: true, default: 0 },
+ lockUntil: { type: Number }
+});
+
+UserSchema.virtual('isLocked').get(function() {
+ // check for a future lockUntil timestamp
+ return !!(this.lockUntil && this.lockUntil > Date.now());
+});
+
+UserSchema.pre('save', function(next) {
+ var user = this;
+
+ // only hash the password if it has been modified (or is new)
+ if (!user.isModified('password')) return next();
+
+ // generate a salt
+ bcrypt.genSalt(SALT_WORK_FACTOR, function(err, salt) {
+ if (err) return next(err);
+
+ // hash the password using our new salt
+ bcrypt.hash(user.password, salt, function (err, hash) {
+ if (err) return next(err);
+
+ // set the hashed password back on our user document
+ user.password = hash;
+ next();
+ });
+ });
+});
+
+UserSchema.methods.comparePassword = function(candidatePassword, cb) {
+ bcrypt.compare(candidatePassword, this.password, function(err, isMatch) {
+ if (err) return cb(err);
+ cb(null, isMatch);
+ });
+};
+
+UserSchema.methods.incLoginAttempts = function(cb) {
+ // if we have a previous lock that has expired, restart at 1
+ if (this.lockUntil && this.lockUntil < Date.now()) {
+ return this.update({
+ $set: { loginAttempts: 1 },
+ $unset: { lockUntil: 1 }
+ }, cb);
+ }
+ // otherwise we're incrementing
+ var updates = { $inc: { loginAttempts: 1 } };
+ // lock the account if we've reached max attempts and it's not locked already
+ if (this.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked) {
+ updates.$set = { lockUntil: Date.now() + LOCK_TIME };
+ }
+ return this.update(updates, cb);
+};
+
+// expose enum on the model, and provide an internal convenience reference
+var reasons = UserSchema.statics.failedLogin = {
+ NOT_FOUND: 0,
+ PASSWORD_INCORRECT: 1,
+ MAX_ATTEMPTS: 2
+};
+
+UserSchema.statics.getAuthenticated = function(username, password, cb) {
+ this.findOne({ username: username }, function(err, user) {
+ if (err) return cb(err);
+
+ // make sure the user exists
+ if (!user) {
+ return cb(null, null, reasons.NOT_FOUND);
+ }
+
+ // check if the account is currently locked
+ if (user.isLocked) {
+ // just increment login attempts if account is already locked
+ return user.incLoginAttempts(function(err) {
+ if (err) return cb(err);
+ return cb(null, null, reasons.MAX_ATTEMPTS);
+ });
+ }
+
+ // test for a matching password
+ user.comparePassword(password, function(err, isMatch) {
+ if (err) return cb(err);
+
+ // check if the password was a match
+ if (isMatch) {
+ // if there's no lock or failed attempts, just return the user
+ if (!user.loginAttempts && !user.lockUntil) return cb(null, user);
+ // reset attempts and lock info
+ var updates = {
+ $set: { loginAttempts: 0 },
+ $unset: { lockUntil: 1 }
+ };
+ return user.update(updates, function(err) {
+ if (err) return cb(err);
+ return cb(null, user);
+ });
+ }
+
+ // password is incorrect, so increment login attempts before responding
+ user.incLoginAttempts(function(err) {
+ if (err) return cb(err);
+ return cb(null, null, reasons.PASSWORD_INCORRECT);
+ });
+ });
+ });
+};
+
+module.exports = mongoose.model('User', UserSchema);
+```
+
+## Sample Usage
+
+Assuming that you've saved the above code as `user-model.js`, here's how you would go about using it:
+
+```
+var mongoose = require('mongoose'),
+ User = require('./user-model');
+
+var connStr = 'mongodb://localhost:27017/mongoose-bcrypt-test';
+mongoose.connect(connStr, function(err) {
+ if (err) throw err;
+ console.log('Successfully connected to MongoDB');
+});
+
+// create a user a new user
+var testUser = new User({
+ username: 'jmar777',
+ password: 'Password123'
+});
+
+// save user to database
+testUser.save(function(err) {
+ if (err) throw err;
+
+ // attempt to authenticate user
+ User.getAuthenticated('jmar777', 'Password123', function(err, user, reason) {
+ if (err) throw err;
+
+ // login was successful if we have a user
+ if (user) {
+ // handle login success
+ console.log('login success');
+ return;
+ }
+
+ // otherwise we can determine why we failed
+ var reasons = User.failedLogin;
+ switch (reason) {
+ case reasons.NOT_FOUND:
+ case reasons.PASSWORD_INCORRECT:
+ // note: these cases are usually treated the same - don't tell
+ // the user *why* the login failed, only that it did
+ break;
+ case reasons.MAX_ATTEMPTS:
+ // send email or otherwise notify user that account is
+ // temporarily locked
+ break;
+ }
+ });
+});
+```
+
+*Thanks for reading!*
Something went wrong with that request. Please try again.