From 9de46f6d55cd941387358db06b7eaba052a59ff8 Mon Sep 17 00:00:00 2001 From: Andrew Gray Date: Tue, 10 Aug 2021 17:04:56 +0930 Subject: [PATCH 1/4] created options validator --- lib/options-validator.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 lib/options-validator.js diff --git a/lib/options-validator.js b/lib/options-validator.js new file mode 100644 index 00000000..e69de29b From 01b3f069ad1fac402b37086becbe2d970bab770e Mon Sep 17 00:00:00 2001 From: Andrew Gray Date: Tue, 10 Aug 2021 19:48:01 +0930 Subject: [PATCH 2/4] updated to use new options validator --- index.js | 72 ++++---------------------------------------------------- 1 file changed, 4 insertions(+), 68 deletions(-) diff --git a/index.js b/index.js index f366fa21..bc92dee8 100644 --- a/index.js +++ b/index.js @@ -4,75 +4,11 @@ const LocalStrategy = require('passport-local').Strategy; const pbkdf2 = require('./lib/pbkdf2'); const errors = require('./lib/errors'); const authenticate = require('./lib/authenticate'); +const validateOptions = require('./lib/options-validator'); -module.exports = function(schema, options) { - options = options || {}; - options.saltlen = options.saltlen || 32; - options.iterations = options.iterations || 25000; - options.keylen = options.keylen || 512; - options.encoding = options.encoding || 'hex'; - options.digestAlgorithm = options.digestAlgorithm || 'sha256'; // To get a list of supported hashes use crypto.getHashes() - - function defaultPasswordValidator(password, cb) { - cb(null); - } - - function defaultPasswordValidatorAsync(password) { - return new Promise((resolve, reject) => { - options.passwordValidator(password, err => (err ? reject(err) : resolve())); - }); - } - - options.passwordValidator = options.passwordValidator || defaultPasswordValidator; - options.passwordValidatorAsync = options.passwordValidatorAsync || defaultPasswordValidatorAsync; - - // Populate field names with defaults if not set - options.usernameField = options.usernameField || 'username'; - options.usernameUnique = options.usernameUnique === undefined ? true : options.usernameUnique; - - // Populate username query fields with defaults if not set, - // otherwise add username field to query fields. - if (options.usernameQueryFields) { - options.usernameQueryFields.push(options.usernameField); - } else { - options.usernameQueryFields = [options.usernameField]; - } - - // option to find username case insensitively - options.usernameCaseInsensitive = Boolean(options.usernameCaseInsensitive || false); - - // option to convert username to lowercase when finding - options.usernameLowerCase = options.usernameLowerCase || false; - - options.hashField = options.hashField || 'hash'; - options.saltField = options.saltField || 'salt'; - - if (options.limitAttempts) { - options.lastLoginField = options.lastLoginField || 'last'; - options.attemptsField = options.attemptsField || 'attempts'; - options.interval = options.interval || 100; // 100 ms - options.maxInterval = options.maxInterval || 300000; // 5 min - options.maxAttempts = options.maxAttempts || Infinity; - } - - options.findByUsername = - options.findByUsername || - function(model, queryParameters) { - return model.findOne(queryParameters); - }; - - options.errorMessages = options.errorMessages || {}; - options.errorMessages.MissingPasswordError = options.errorMessages.MissingPasswordError || 'No password was given'; - options.errorMessages.AttemptTooSoonError = options.errorMessages.AttemptTooSoonError || 'Account is currently locked. Try again later'; - options.errorMessages.TooManyAttemptsError = - options.errorMessages.TooManyAttemptsError || 'Account locked due to too many failed login attempts'; - options.errorMessages.NoSaltValueStoredError = - options.errorMessages.NoSaltValueStoredError || 'Authentication not possible. No salt value stored'; - options.errorMessages.IncorrectPasswordError = options.errorMessages.IncorrectPasswordError || 'Password or username is incorrect'; - options.errorMessages.IncorrectUsernameError = options.errorMessages.IncorrectUsernameError || 'Password or username is incorrect'; - options.errorMessages.MissingUsernameError = options.errorMessages.MissingUsernameError || 'No username was given'; - options.errorMessages.UserExistsError = options.errorMessages.UserExistsError || 'A user with the given username is already registered'; - +module.exports = function(schema, inputOptions) { + const options = validateOptions(inputOptions); + const schemaFields = {}; if (!schema.path(options.usernameField)) { From 412579abc292ecda996fe67b5c5c2c7f878b902a Mon Sep 17 00:00:00 2001 From: Andrew Gray Date: Tue, 10 Aug 2021 19:51:25 +0930 Subject: [PATCH 3/4] options validator that populates defaults and keeps user preferences --- lib/options-validator.js | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/lib/options-validator.js b/lib/options-validator.js index e69de29b..319dba95 100644 --- a/lib/options-validator.js +++ b/lib/options-validator.js @@ -0,0 +1,75 @@ +function defaultPasswordValidator(password, cb) { + // no validation, returns a non error result + cb(null); +} + +function createPasswordValidatorAsync(passwordValidator) { + return function (password) { + return new Promise((resolve, reject) => { + passwordValidator(password, (err) => (err ? reject(err) : resolve())); + }); + }; +} + +function defaultFindByUsername(model, queryParameters) { + return model.findOne(queryParameters); +} + +const defaultOptions = { + saltlen: 32, + iterations: 25000, + keylen: 512, + encoding: 'hex', + digestAlgorithm: 'sha256', + passwordValidator: defaultPasswordValidator, + usernameField: 'username', + usernameUnique: true, + usernameCaseInsensitive: false, + usernameLowerCase: false, + hashField: 'hash', + saltField: 'salt', + limitAttempts: false, + findByUsername: defaultFindByUsername, +}; + +const defaultLimitAttemptOptions = { + lastLoginField: 'last', + attemptsField: 'attempts', + interval: 100, // 100 ms + maxInterval: 5 * 60 * 1000, // 5 min + maxAttempts: Infinity, +}; + +const defaultErrorMessages = { + MissingPasswordError: 'No password was given', + AttemptTooSoonError: 'Account is currently locked. Try again later', + TooManyAttemptsError: 'Account locked due to too many failed login attempts', + NoSaltValueStoredError: 'Authentication not possible. No salt value stored', + IncorrectPasswordError: 'Password or username is incorrect', + IncorrectUsernameError: 'Password or username is incorrect', + MissingUsernameError: 'No username was given', + UserExistsError: 'A user with the given username is already registered', +}; + +module.exports = function (inputOptions = {}) { + // If limiting attempts, ensure all required fields are set + const options = inputOptions.limitAttempts + ? Object.assign({}, defaultOptions, defaultLimitAttemptOptions, inputOptions) + : Object.assign({}, defaultOptions, inputOptions); + + // Create Async password validator if not set + options.passwordValidatorAsync = options.passwordValidatorAsync || createPasswordValidatorAsync(options.passwordValidator); + + // Populate username query fields with defaults if not set, + // otherwise add username field to query fields. + if (Array.isArray(options.usernameQueryFields)) { + options.usernameQueryFields.push(options.usernameField); + } else { + options.usernameQueryFields = [options.usernameField]; + } + + // Set error messages to defaults if not set + options.errorMessages = Object.assign({}, defaultErrorMessages, options.errorMessages); + + return options; +}; From ba805ff4297a18a0da0c5732aded00358ce925b0 Mon Sep 17 00:00:00 2001 From: Andrew Gray Date: Tue, 10 Aug 2021 21:23:23 +0930 Subject: [PATCH 4/4] moved schema setup to separate files --- index.js | 247 ++------------------------------------ lib/schema-add-fields.js | 15 +++ lib/schema-add-hooks.js | 9 ++ lib/schema-add-methods.js | 104 ++++++++++++++++ lib/schema-add-statics.js | 121 +++++++++++++++++++ 5 files changed, 258 insertions(+), 238 deletions(-) create mode 100644 lib/schema-add-fields.js create mode 100644 lib/schema-add-hooks.js create mode 100644 lib/schema-add-methods.js create mode 100644 lib/schema-add-statics.js diff --git a/index.js b/index.js index bc92dee8..f866ad9b 100644 --- a/index.js +++ b/index.js @@ -1,249 +1,20 @@ -const crypto = require('crypto'); -const LocalStrategy = require('passport-local').Strategy; - -const pbkdf2 = require('./lib/pbkdf2'); const errors = require('./lib/errors'); -const authenticate = require('./lib/authenticate'); const validateOptions = require('./lib/options-validator'); +const addSchemaFields = require('./lib/schema-add-fields'); +const addSchemaHooks = require('./lib/schema-add-hooks'); +const addSchemaMethods = require('./lib/schema-add-methods'); +const addSchemaStatics = require('./lib/schema-add-statics'); -module.exports = function(schema, inputOptions) { +module.exports = function (schema, inputOptions) { const options = validateOptions(inputOptions); - - const schemaFields = {}; - - if (!schema.path(options.usernameField)) { - schemaFields[options.usernameField] = { type: String, unique: options.usernameUnique }; - } - schemaFields[options.hashField] = { type: String, select: false }; - schemaFields[options.saltField] = { type: String, select: false }; - - if (options.limitAttempts) { - schemaFields[options.attemptsField] = { type: Number, default: 0 }; - schemaFields[options.lastLoginField] = { type: Date, default: Date.now }; - } - - schema.add(schemaFields); - - schema.pre('save', function(next) { - if (options.usernameLowerCase && this[options.usernameField]) { - this[options.usernameField] = this[options.usernameField].toLowerCase(); - } - - next(); - }); - - schema.methods.setPassword = function(password, cb) { - const promise = Promise.resolve() - .then(() => { - if (!password) { - throw new errors.MissingPasswordError(options.errorMessages.MissingPasswordError); - } - }) - .then(() => options.passwordValidatorAsync(password)) - .then(() => randomBytes(options.saltlen)) - .then(saltBuffer => saltBuffer.toString(options.encoding)) - .then(salt => { - this.set(options.saltField, salt); - - return salt; - }) - .then(salt => pbkdf2Promisified(password, salt, options)) - .then(hashRaw => { - this.set(options.hashField, Buffer.from(hashRaw, 'binary').toString(options.encoding)); - }) - .then(() => this); - - if (!cb) { - return promise; - } - - promise.then(result => cb(null, result)).catch(err => cb(err)); - }; - - schema.methods.changePassword = function(oldPassword, newPassword, cb) { - const promise = Promise.resolve() - .then(() => { - if (!oldPassword || !newPassword) { - throw new errors.MissingPasswordError(options.errorMessages.MissingPasswordError); - } - }) - .then(() => this.authenticate(oldPassword)) - .then(({ user }) => { - if (!user) { - throw new errors.IncorrectPasswordError(options.errorMessages.IncorrectPasswordError); - } - }) - .then(() => this.setPassword(newPassword)) - .then(() => this.save()) - .then(() => this); - - if (!cb) { - return promise; - } - - promise.then(result => cb(null, result)).catch(err => cb(err)); - }; - - schema.methods.authenticate = function(password, cb) { - const promise = Promise.resolve().then(() => { - if (this.get(options.saltField)) { - return authenticate(this, password, options); - } - - return this.constructor.findByUsername(this.get(options.usernameField), true).then(user => { - if (user) { - return authenticate(user, password, options); - } - - return { user: false, error: new errors.IncorrectUsernameError(options.errorMessages.IncorrectUsernameError) }; - }); - }); - - if (!cb) { - return promise; - } - - promise.then(({ user, error }) => cb(null, user, error)).catch(err => cb(err)); - }; - - if (options.limitAttempts) { - schema.methods.resetAttempts = function(cb) { - const promise = Promise.resolve().then(() => { - this.set(options.attemptsField, 0); - return this.save(); - }); - - if (!cb) { - return promise; - } - - promise.then(result => cb(null, result)).catch(err => cb(err)); - }; - } - // Passport Local Interface - schema.statics.authenticate = function() { - return (username, password, cb) => { - const promise = Promise.resolve() - .then(() => this.findByUsername(username, true)) - .then(user => { - if (user) { - return user.authenticate(password); - } + addSchemaFields(schema, options); - return { user: false, error: new errors.IncorrectUsernameError(options.errorMessages.IncorrectUsernameError) }; - }); + addSchemaHooks(schema, options); - if (!cb) { - return promise; - } + addSchemaMethods(schema, options); - promise.then(({ user, error }) => cb(null, user, error)).catch(err => cb(err)); - }; - }; - - // Passport Interface - schema.statics.serializeUser = function() { - return function(user, cb) { - cb(null, user.get(options.usernameField)); - }; - }; - - schema.statics.deserializeUser = function() { - return (username, cb) => { - this.findByUsername(username, cb); - }; - }; - - schema.statics.register = function(user, password, cb) { - // Create an instance of this in case user isn't already an instance - if (!(user instanceof this)) { - user = new this(user); - } - - const promise = Promise.resolve() - .then(() => { - if (!user.get(options.usernameField)) { - throw new errors.MissingUsernameError(options.errorMessages.MissingUsernameError); - } - }) - .then(() => this.findByUsername(user.get(options.usernameField))) - .then(existingUser => { - if (existingUser) { - throw new errors.UserExistsError(options.errorMessages.UserExistsError); - } - }) - .then(() => user.setPassword(password)) - .then(() => user.save()); - - if (!cb) { - return promise; - } - - promise.then(result => cb(null, result)).catch(err => cb(err)); - }; - - schema.statics.findByUsername = function(username, opts, cb) { - if (typeof opts === 'function') { - cb = opts; - opts = {}; - } - - if (typeof opts == 'boolean') { - opts = { - selectHashSaltFields: opts - }; - } - - opts = opts || {}; - opts.selectHashSaltFields = !!opts.selectHashSaltFields; - - // if specified, convert the username to lowercase - if (username !== undefined && options.usernameLowerCase) { - username = username.toLowerCase(); - } - - // Add each username query field - const queryOrParameters = []; - for (let i = 0; i < options.usernameQueryFields.length; i++) { - const parameter = {}; - parameter[options.usernameQueryFields[i]] = options.usernameCaseInsensitive ? new RegExp(`^${username}$`, 'i') : username; - queryOrParameters.push(parameter); - } - - const query = options.findByUsername(this, { $or: queryOrParameters }); - - if (opts.selectHashSaltFields) { - query.select('+' + options.hashField + ' +' + options.saltField); - } - - if (options.selectFields) { - query.select(options.selectFields); - } - - if (options.populateFields) { - query.populate(options.populateFields); - } - - if (cb) { - query.exec(cb); - return; - } - - return query; - }; - - schema.statics.createStrategy = function() { - return new LocalStrategy(options, this.authenticate()); - }; + addSchemaStatics(schema, options); }; -function pbkdf2Promisified(password, salt, options) { - return new Promise((resolve, reject) => pbkdf2(password, salt, options, (err, hashRaw) => (err ? reject(err) : resolve(hashRaw)))); -} - -function randomBytes(saltlen) { - return new Promise((resolve, reject) => crypto.randomBytes(saltlen, (err, saltBuffer) => (err ? reject(err) : resolve(saltBuffer)))); -} - module.exports.errors = errors; diff --git a/lib/schema-add-fields.js b/lib/schema-add-fields.js new file mode 100644 index 00000000..2ba8c799 --- /dev/null +++ b/lib/schema-add-fields.js @@ -0,0 +1,15 @@ +module.exports = function (schema, options) { + const schemaFields = {}; + if (!schema.path(options.usernameField)) { + schemaFields[options.usernameField] = { type: String, unique: options.usernameUnique }; + } + schemaFields[options.hashField] = { type: String, select: false }; + schemaFields[options.saltField] = { type: String, select: false }; + + if (options.limitAttempts) { + schemaFields[options.attemptsField] = { type: Number, default: 0 }; + schemaFields[options.lastLoginField] = { type: Date, default: Date.now }; + } + + schema.add(schemaFields); +}; diff --git a/lib/schema-add-hooks.js b/lib/schema-add-hooks.js new file mode 100644 index 00000000..9b2e6644 --- /dev/null +++ b/lib/schema-add-hooks.js @@ -0,0 +1,9 @@ +module.exports = function (schema, options) { + schema.pre('save', function (next) { + if (options.usernameLowerCase && this[options.usernameField]) { + this[options.usernameField] = this[options.usernameField].toLowerCase(); + } + + next(); + }); +}; diff --git a/lib/schema-add-methods.js b/lib/schema-add-methods.js new file mode 100644 index 00000000..0c7093ac --- /dev/null +++ b/lib/schema-add-methods.js @@ -0,0 +1,104 @@ +const crypto = require('crypto'); + +const pbkdf2 = require('./pbkdf2'); +const errors = require('./errors'); +const authenticate = require('./authenticate'); + +function pbkdf2Promisified(password, salt, options) { + return new Promise((resolve, reject) => pbkdf2(password, salt, options, (err, hashRaw) => (err ? reject(err) : resolve(hashRaw)))); +} + +function randomBytes(saltlen) { + return new Promise((resolve, reject) => crypto.randomBytes(saltlen, (err, saltBuffer) => (err ? reject(err) : resolve(saltBuffer)))); +} + +module.exports = function addSchemaMethods(schema, options) { + schema.methods.setPassword = function (password, cb) { + const promise = Promise.resolve() + .then(() => { + if (!password) { + throw new errors.MissingPasswordError(options.errorMessages.MissingPasswordError); + } + }) + .then(() => options.passwordValidatorAsync(password)) + .then(() => randomBytes(options.saltlen)) + .then((saltBuffer) => saltBuffer.toString(options.encoding)) + .then((salt) => { + this.set(options.saltField, salt); + + return salt; + }) + .then((salt) => pbkdf2Promisified(password, salt, options)) + .then((hashRaw) => { + this.set(options.hashField, Buffer.from(hashRaw, 'binary').toString(options.encoding)); + }) + .then(() => this); + + if (!cb) { + return promise; + } + + promise.then((result) => cb(null, result)).catch((err) => cb(err)); + }; + + schema.methods.changePassword = function (oldPassword, newPassword, cb) { + const promise = Promise.resolve() + .then(() => { + if (!oldPassword || !newPassword) { + throw new errors.MissingPasswordError(options.errorMessages.MissingPasswordError); + } + }) + .then(() => this.authenticate(oldPassword)) + .then(({ user }) => { + if (!user) { + throw new errors.IncorrectPasswordError(options.errorMessages.IncorrectPasswordError); + } + }) + .then(() => this.setPassword(newPassword)) + .then(() => this.save()) + .then(() => this); + + if (!cb) { + return promise; + } + + promise.then((result) => cb(null, result)).catch((err) => cb(err)); + }; + + schema.methods.authenticate = function (password, cb) { + const promise = Promise.resolve().then(() => { + if (this.get(options.saltField)) { + return authenticate(this, password, options); + } + + return this.constructor.findByUsername(this.get(options.usernameField), true).then((user) => { + if (user) { + return authenticate(user, password, options); + } + + return { user: false, error: new errors.IncorrectUsernameError(options.errorMessages.IncorrectUsernameError) }; + }); + }); + + if (!cb) { + return promise; + } + + promise.then(({ user, error }) => cb(null, user, error)).catch((err) => cb(err)); + }; + + if (options.limitAttempts) { + schema.methods.resetAttempts = function (cb) { + const promise = Promise.resolve().then(() => { + this.set(options.attemptsField, 0); + return this.save(); + }); + + if (!cb) { + return promise; + } + + promise.then((result) => cb(null, result)).catch((err) => cb(err)); + }; + } +}; diff --git a/lib/schema-add-statics.js b/lib/schema-add-statics.js new file mode 100644 index 00000000..c2cbe6e8 --- /dev/null +++ b/lib/schema-add-statics.js @@ -0,0 +1,121 @@ +const LocalStrategy = require('passport-local').Strategy; + +const errors = require('./errors'); + +module.exports = function addSchemaStatics(schema, options) { + // Passport Local Interface + schema.statics.authenticate = function () { + return (username, password, cb) => { + const promise = Promise.resolve() + .then(() => this.findByUsername(username, true)) + .then((user) => { + if (user) { + return user.authenticate(password); + } + + return { user: false, error: new errors.IncorrectUsernameError(options.errorMessages.IncorrectUsernameError) }; + }); + + if (!cb) { + return promise; + } + + promise.then(({ user, error }) => cb(null, user, error)).catch((err) => cb(err)); + }; + }; + + // Passport Interface + schema.statics.serializeUser = function () { + return function (user, cb) { + cb(null, user.get(options.usernameField)); + }; + }; + + schema.statics.deserializeUser = function () { + return (username, cb) => { + this.findByUsername(username, cb); + }; + }; + + schema.statics.register = function (user, password, cb) { + // Create an instance of this in case user isn't already an instance + if (!(user instanceof this)) { + user = new this(user); + } + + const promise = Promise.resolve() + .then(() => { + if (!user.get(options.usernameField)) { + throw new errors.MissingUsernameError(options.errorMessages.MissingUsernameError); + } + }) + .then(() => this.findByUsername(user.get(options.usernameField))) + .then((existingUser) => { + if (existingUser) { + throw new errors.UserExistsError(options.errorMessages.UserExistsError); + } + }) + .then(() => user.setPassword(password)) + .then(() => user.save()); + + if (!cb) { + return promise; + } + + promise.then((result) => cb(null, result)).catch((err) => cb(err)); + }; + + schema.statics.findByUsername = function (username, opts, cb) { + if (typeof opts === 'function') { + cb = opts; + opts = {}; + } + + if (typeof opts == 'boolean') { + opts = { + selectHashSaltFields: opts, + }; + } + + opts = opts || {}; + opts.selectHashSaltFields = !!opts.selectHashSaltFields; + + // if specified, convert the username to lowercase + if (username !== undefined && options.usernameLowerCase) { + username = username.toLowerCase(); + } + + // Add each username query field + const queryOrParameters = []; + for (let i = 0; i < options.usernameQueryFields.length; i++) { + const parameter = {}; + parameter[options.usernameQueryFields[i]] = options.usernameCaseInsensitive ? new RegExp(`^${username}$`, 'i') : username; + queryOrParameters.push(parameter); + } + + const query = options.findByUsername(this, { $or: queryOrParameters }); + + if (opts.selectHashSaltFields) { + query.select('+' + options.hashField + ' +' + options.saltField); + } + + if (options.selectFields) { + query.select(options.selectFields); + } + + if (options.populateFields) { + query.populate(options.populateFields); + } + + if (cb) { + query.exec(cb); + return; + } + + return query; + }; + + schema.statics.createStrategy = function () { + return new LocalStrategy(options, this.authenticate()); + }; +};