Created
March 7, 2015 01:59
-
-
Save nijikokun/b004ecb377676c32673f to your computer and use it in GitHub Desktop.
Mongoose Passport LocalStrategy Plugin, supports PBKDF2, BCrypt, MD5
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var LocalStrategy = require('passport-local').Strategy; | |
var crypto = require('crypto'); | |
var bcrypt = require('bcrypt'); | |
var util = require('util'); | |
function BadRequestError (message) { | |
Error.call(this); | |
Error.captureStackTrace(this, arguments.callee); | |
this.name = 'BadRequestError'; | |
this.message = message || null; | |
} | |
util.inherits(BadRequestError, Error); | |
module.exports = function (schema, options) { | |
var schemaFields = {}; | |
// Ensure options is an object | |
options = options || {}; | |
// Field names | |
options.usernameField = options.usernameField || 'username'; | |
options.methodField = options.methodField || 'hashMethod'; | |
options.hashField = options.hashField || 'hash'; | |
options.saltField = options.saltField || 'salt'; | |
// Encryption option, ensure casing | |
options.method = options.method || 'bcrypt'; | |
options.method = options.method.toLowerCase(); | |
// BCrypt Options | |
options.saltWorkFactor = options.saltWorkFactor || 10; | |
// PBKDF2 options | |
options.encoding = options.encoding || 'hex'; | |
options.saltLength = options.saltLength || 32; | |
options.hashLength = options.hashLength || 512; | |
options.hashIterations = options.hashIterations || 64000; | |
// Convert username to lowercase? | |
options.usernameLowerCase = options.usernameLowerCase || false; | |
// Error messages | |
options.incorrectPasswordError = options.incorrectPasswordError || 'Invalid password given'; | |
options.incorrectUsernameError = options.incorrectUsernameError || 'Invalid %s given'; | |
options.missingUsernameError = options.missingUsernameError || '%s is required'; | |
options.missingPasswordError = options.missingPasswordError || 'Password is required'; | |
options.userExistsError = options.userExistsError || 'User already exists with %s %s'; | |
options.noSaltValueStoredError = options.noSaltValueStoredError || 'Authentication not possible. No salt value stored!'; | |
options.attemptTooSoonError = options.attemptTooSoonError || 'Login attempted too soon after previous attempt'; | |
// Prevent brute forcing? | |
if (options.limitAttempts) { | |
options.lastLoginField = options.lastLoginField || 'last'; | |
options.attemptsField = options.attemptsField || 'attempts'; | |
options.interval = options.interval || 100; // in ms | |
schemaFields[options.attemptsField] = { | |
type: Number, default: 0 | |
}; | |
schemaFields[options.lastLoginField] = { | |
type: Date, default: Date.now | |
}; | |
} | |
// Ensure username field exists | |
if (!schema.path(options.usernameField)) { | |
schemaFields[options.usernameField] = String; | |
} | |
// Setup hashing fields | |
schemaFields[options.hashField] = String; | |
schemaFields[options.saltField] = String; | |
schemaFields[options.methodField] = String; | |
// Save fields | |
schema.add(schemaFields); | |
// Ensure username is lowercase on save when flag is set | |
schema.pre('save', function (next) { | |
// if specified, convert the username to lowercase | |
if (options.usernameLowerCase) { | |
this[options.usernameField] = this[options.usernameField].toLowerCase(); | |
} | |
next(); | |
}); | |
/** | |
* Asynchronous method to set a user's password hash and salt. | |
* | |
* @param {String} password Account password | |
* @param {Function} cb Callback | |
*/ | |
schema.methods.setPassword = function (password, cb) { | |
if (!password) { | |
return cb(new BadRequestError(options.missingPasswordError)); | |
} | |
var self = this; | |
switch (options.method) { | |
case "bcrypt": | |
bcrypt.genSalt(options.saltWorkFactor, function (err, salt) { | |
if (err) { | |
return cb(err); | |
} | |
bcrypt.hash(password, salt, function (err, hash) { | |
if (err) { | |
return cb(err); | |
} | |
self.set(options.hashField, hash); | |
self.set(options.methodField, 'bcrypt'); | |
cb(null, self); | |
}); | |
}); | |
break; | |
case "pbkdf2": | |
crypto.randomBytes(options.saltLength, function (err, buf) { | |
if (err) { | |
return cb(err); | |
} | |
var salt = buf.toString(options.encoding); | |
crypto.pbkdf2(password, salt, options.hashIterations, options.hashLength, function (err, hashRaw) { | |
if (err) { | |
return cb(err); | |
} | |
self.set(options.hashField, new Buffer(hashRaw, 'binary').toString(options.encoding)); | |
self.set(options.saltField, salt); | |
self.set(options.methodField, 'pbkdf2'); | |
cb(null, self); | |
}); | |
}); | |
break; | |
} | |
}; | |
/** | |
* Compares stored hash against md5 checksum | |
* | |
* Returns | |
* - Error | |
* - isMatch (True / False) | |
* | |
* @param {String} password Specified password to compare | |
* @param {Function} cb Callback | |
*/ | |
schema.methods.comparePasswordMD5 = function (password, cb) { | |
return cb(null, crypto.createHash('md5').update(password).digest("hex") === this.get(options.hashField)); | |
}; | |
/** | |
* Compares stored hash against bcrypt hashing | |
* | |
* Returns | |
* - Error | |
* - isMatch (True / False) | |
* | |
* @param {String} password Specified password to compare | |
* @param {Function} cb Callback | |
*/ | |
schema.methods.comparePasswordBcrypt = function (password, cb) { | |
bcrypt.compare(password, this.get(options.hashField), cb); | |
}; | |
/** | |
* Compare password and stored hash + salt using PBKDF2 | |
* | |
* Returns | |
* - Error | |
* - isMatch (True / False) | |
* | |
* @param {String} password Specified password to compare | |
* @param {Function} cb Callback | |
*/ | |
schema.methods.comparePasswordPbkdf2 = function (password, cb) { | |
var self = this; | |
crypto.pbkdf2(password, this.get(options.saltField), options.hashIterations, options.hashLength, function (err, hashRaw) { | |
if (err) { | |
return cb(err); | |
} | |
if (new Buffer(hashRaw, 'binary').toString(options.encoding) === self.get(options.hashField)) { | |
return cb(null, true); | |
} | |
return cb(null, false); | |
}); | |
}; | |
/** | |
* Authenticate current user with brute forcing prevention when enabled. | |
* | |
* @param {String} password Specified password to compare | |
* @param {Function} cb Callback | |
*/ | |
schema.methods.authenticate = function (password, cb) { | |
var self = this; | |
var method = this.get(options.methodField) || options.method; | |
// Check whether login attempt is too soon after the previous attempt | |
if (options.limitAttempts && (Date.now() - this.get(options.lastLoginField) < Math.pow(options.interval, this.get(options.attemptsField) + 1))) { | |
this.set(options.lastLoginField, Date.now()); | |
self.save(); | |
return cb(null, false, { message: options.attemptTooSoonError }); | |
} | |
// Check salt value exists | |
if (!this.get(options.saltField)) { | |
return cb(null, false, { message: options.noSaltValueStoredError }); | |
} | |
// On Success | |
function success () { | |
if (options.limitAttempts) { | |
self.set(options.lastLoginField, Date.now()); | |
self.set(options.attemptsField, 0); | |
self.save(); | |
} | |
return cb(null, self); | |
} | |
// On Failure | |
function failure () { | |
if (options.limitAttempts) { | |
self.set(options.lastLoginField, Date.now()); | |
self.set(options.attemptsField, self.get(options.attemptsField) + 1); | |
self.save(); | |
} | |
return cb(null, false, { message: options.incorrectPasswordError }); | |
} | |
// Generic comparison handler | |
function generic (err, matches) { | |
if (err) { | |
return cb(err); | |
} | |
return matches ? success() : failure(); | |
} | |
switch (method.toLowerCase()) { | |
case "bcrypt": | |
self.comparePasswordBcrypt(password, generic); | |
break; | |
case "pbkdf2": | |
self.comparePasswordPbkdf2(password, generic); | |
break; | |
case "md5": | |
self.comparePasswordMD5(password, generic); | |
break; | |
} | |
}; | |
/** | |
* Generates a function to be used in Passport's LocalStrategy | |
* | |
* @return {Function} | |
*/ | |
schema.statics.authenticate = function () { | |
var self = this; | |
return function (username, password, cb) { | |
self.findByUsername(username, function (err, user) { | |
if (err) { | |
return cb(err); | |
} | |
if (user) { | |
return user.authenticate(password, cb); | |
} else { | |
return cb(null, false, { | |
message: util.format(options.incorrectUsernameError, options.usernameField) | |
}); | |
} | |
}); | |
}; | |
}; | |
/** | |
* Generates a function to be used by Passport to serialize users into the session | |
* | |
* @return {Function} | |
*/ | |
schema.statics.serializeUser = function () { | |
return function (user, cb) { | |
cb(null, user.get(options.usernameField)); | |
}; | |
}; | |
/** | |
* Generates a function to be used by Passport to deserialize users into the session | |
* | |
* @return {Function} | |
*/ | |
schema.statics.deserializeUser = function () { | |
var self = this; | |
return function (username, cb) { | |
self.findByUsername(username, cb); | |
}; | |
}; | |
/** | |
* Convenience method to register a new user instance with a given password. Checks if username is unique. | |
* | |
* @param {String} user Account Username | |
* @param {String} password Account password | |
* @param {Function} cb Callback | |
*/ | |
schema.statics.register = function (user, password, cb) { | |
var self = this; | |
// Instantiate user if the user isn't already an instance | |
if (!(user instanceof this)) { | |
user = new this(user); | |
} | |
// Ensure user has the username field | |
if (!user.get(options.usernameField)) { | |
return cb(new BadRequestError(util.format(options.missingUsernameError, options.usernameField))); | |
} | |
self.findByUsername(user.get(options.usernameField), function (err, existingUser) { | |
if (err) { | |
return cb(err); | |
} | |
if (existingUser) { | |
return cb(new BadRequestError( | |
util.format(options.userExistsError, options.usernameField, user.get(options.usernameField)) | |
)); | |
} | |
user.setPassword(password, function(err, user) { | |
if (err) { | |
return cb(err); | |
} | |
user.save(function(err) { | |
if (err) { | |
return cb(err); | |
} | |
cb(null, user); | |
}); | |
}); | |
}); | |
}; | |
/** | |
* Convenience method to find a user instance by it's unique username. | |
* | |
* @param {String} username Account username | |
* @param {Function} cb Callback | |
* @return {Query|void} When cb is not defined, the query instance is returned. | |
*/ | |
schema.statics.findByUsername = function (username, cb) { | |
var queryParameters = {}; | |
var query; | |
// if specified, convert the username to lowercase | |
if (username !== undefined && options.usernameLowerCase) { | |
username = username.toLowerCase(); | |
} | |
queryParameters[options.usernameField] = username; | |
query = this.findOne(queryParameters); | |
if (options.selectFields) { | |
query.select(options.selectFields); | |
} | |
if (options.populateFields) { | |
query.populate(options.populateFields); | |
} | |
if (cb) { | |
query.exec(cb); | |
} else { | |
return query; | |
} | |
}; | |
/** | |
* Creates a configured passport-local LocalStrategy instance that can be used in passport. | |
* | |
* @return {LocalStrategy} | |
*/ | |
schema.statics.createStrategy = function () { | |
return new LocalStrategy(options, this.authenticate()); | |
}; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment