UNPKG

keystone

Version:

Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose

262 lines (232 loc) 7.63 kB
var _ = require('lodash'); var bcrypt = require('bcrypt-nodejs'); var FieldType = require('../Type'); var util = require('util'); var utils = require('keystone-utils'); var dumbPasswords = require('dumb-passwords'); var regexChunk = { digitChar: /\d/, spChar: /[!@#\$%\^&\*()\+]/, asciiChar: /^[\u0020-\u007E]+$/, lowChar: /[a-z]/, upperChar: /[A-Z]/, }; var detailMsg = { digitChar: 'enter at least one digit', spChar: 'enter at least one special character', asciiChar: 'only ASCII characters are allowed', lowChar: 'use at least one lower case character', upperChar: 'use at least one upper case character', }; const defaultOptions = { min: 8, max: 72, workFactor: 10, rejectCommon: true }; /** * password FieldType Constructor * @extends Field * @api public */ function password (list, path, options) { // Apply default and enforced options (you can't sort on password fields) options = Object.assign({}, defaultOptions, options, { nosort: false }); this._nativeType = String; this._underscoreMethods = ['format', 'compare']; this._fixedSize = 'full'; password.super_.call(this, list, path, options); for (var key in this.options.complexity) { if ({}.hasOwnProperty.call(this.options.complexity, key)) { if (key in regexChunk !== key in this.options.complexity) { throw new Error('FieldType.Password: options.complexity - option does not exist.'); } if (typeof this.options.complexity[key] !== 'boolean') { throw new Error('FieldType.Password: options.complexity - Value must be boolean.'); } } } if (this.options.max && this.options.max < this.options.min) { throw new Error('FieldType.Password: options - maximum password length cannot be less than the minimum length.'); } } password.properName = 'Password'; util.inherits(password, FieldType); /** * Registers the field on the List's Mongoose Schema. * * Adds ... * * @api public */ password.prototype.addToSchema = function (schema) { var field = this; var needs_hashing = '__' + field.path + '_needs_hashing'; this.paths = { confirm: this.options.confirmPath || this.path + '_confirm', hash: this.options.hashPath || this.path + '_hash', }; schema.path(this.path, _.defaults({ type: String, set: function (newValue) { this[needs_hashing] = true; return newValue; }, }, this.options)); schema.virtual(this.paths.hash).set(function (newValue) { this.set(field.path, newValue); this[needs_hashing] = false; }); schema.pre('save', function (next) { if (!this.isModified(field.path) || !this[needs_hashing]) { return next(); } if (!this.get(field.path)) { this.set(field.path, undefined); this[needs_hashing] = false; return next(); } var item = this; bcrypt.genSalt(field.options.workFactor, function (err, salt) { if (err) { return next(err); } bcrypt.hash(item.get(field.path), salt, function () {}, function (err, hash) { if (err) { return next(err); } // override the cleartext password with the hashed one item.set(field.path, hash); // reset [needs_hashing] so that new values can't be hashed more than once // (inherited models double up on pre save handlers for password fields) item[needs_hashing] = false; next(); }); }); }); this.bindUnderscoreMethods(); }; /** * Add filters to a query */ password.prototype.addFilterToQuery = function (filter) { var query = {}; query[this.path] = (filter.exists) ? { $ne: null } : null; return query; }; /** * Retrieves the field value * * Password fields values are returned as booleans to indicate whether a value * has been set or not, so that we don't leak hashed passwords via API * * @api public */ password.prototype.getData = function (item) { return item.get(this.path) ? true : false; }; /** * Formats the field value * * Password fields are always formatted as a random no. of asterisks, * because the saved hash should never be displayed nor the length * of the actual password hinted at. * * @api public */ password.prototype.format = function (item) { if (!item.get(this.path)) return ''; var len = Math.round(Math.random() * 4) + 6; var stars = ''; for (var i = 0; i < len; i++) stars += '*'; return stars; }; /** * Compares * * @api public */ password.prototype.compare = function (item, candidate, callback) { if (typeof callback !== 'function') throw new Error('Password.compare() requires a callback function.'); var value = item.get(this.path); if (!value) return callback(null, false); bcrypt.compare(candidate, item.get(this.path), callback); }; /** * Asynchronously confirms that the provided password is valid */ password.prototype.validateInput = function (data, callback) { var { min, max, complexity, rejectCommon } = this.options; var confirmValue = this.getValueFromData(data, '_confirm'); var passwordValue = this.getValueFromData(data); var validation = validate(passwordValue, confirmValue, min, max, complexity, rejectCommon); utils.defer(callback, validation.result, validation.detail); }; var validate = password.validate = function (pass, confirm, min, max, complexity, rejectCommon) { var messages = []; if (confirm !== undefined && pass !== confirm) { messages.push('Passwords must match.'); } if (min && typeof pass === 'string' && pass.length < min) { messages.push('Password must be longer than ' + min + ' characters.'); } if (max && typeof pass === 'string' && pass.length > max) { messages.push('Password must not be longer than ' + max + ' characters.'); } for (var prop in complexity) { if (complexity[prop] && typeof pass === 'string') { var complexityCheck = (regexChunk[prop]).test(pass); if (!complexityCheck) { messages.push(detailMsg[prop]); } } } if (pass && typeof pass === 'string' && rejectCommon && dumbPasswords.check(pass)) { messages.push('Password must not be a common, frequently-used password.'); } return { result: messages.length === 0, detail: messages.join(' \n'), }; }; /** * Asynchronously confirms that the provided password is valid */ password.prototype.validateRequiredInput = function (item, data, callback) { var hashValue = this.getValueFromData(data, '_hash'); var passwordValue = this.getValueFromData(data); var result = hashValue || passwordValue ? true : false; if (!result && passwordValue === undefined && hashValue === undefined && item.get(this.path)) result = true; utils.defer(callback, result); }; /** * If password fields are required, check that either a value has been * provided or already exists in the field. * * Otherwise, input is always considered valid, as providing an empty * value will not change the password. * * Deprecated */ password.prototype.inputIsValid = function (data, required, item) { if (data[this.path] && this.paths.confirm in data) { return data[this.path] === data[this.paths.confirm] ? true : false; } if (data[this.path] || data[this.paths.hash] || (item && item.get(this.path))) return true; return required ? false : true; }; /** * Updates the value for this field in the item from a data object * * Will accept either the field path, or paths.hash to bypass bcrypt * * @api public */ password.prototype.updateItem = function (item, data, callback) { var hashValue = this.getValueFromData(data, '_hash'); var passwordValue = this.getValueFromData(data); if (passwordValue !== undefined) { item.set(this.path, passwordValue); } else if (hashValue !== undefined) { item.set(this.paths.hash, hashValue); } process.nextTick(callback); }; /* Export Field Type */ module.exports = password;