// Copyright IBM Corp. 2014,2016. All Rights Reserved. // Node module: loopback // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT /*! * Module Dependencies. */ 'use strict'; var g = require('../../lib/globalize'); var isEmail = require('isemail'); var loopback = require('../../lib/loopback'); var utils = require('../../lib/utils'); var path = require('path'); var qs = require('querystring'); var SALT_WORK_FACTOR = 10; var crypto = require('crypto'); var MAX_PASSWORD_LENGTH = 72; var bcrypt; try { // Try the native module first bcrypt = require('bcrypt'); // Browserify returns an empty object if (bcrypt && typeof bcrypt.compare !== 'function') { bcrypt = require('bcryptjs'); } } catch (err) { // Fall back to pure JS impl bcrypt = require('bcryptjs'); } var DEFAULT_TTL = 1209600; // 2 weeks in seconds var DEFAULT_RESET_PW_TTL = 15 * 60; // 15 mins in seconds var DEFAULT_MAX_TTL = 31556926; // 1 year in seconds var assert = require('assert'); var debug = require('debug')('loopback:user'); /** * Built-in User model. * Extends LoopBack [PersistedModel](#persistedmodel-new-persistedmodel). * * Default `User` ACLs. * * - DENY EVERYONE `*` * - ALLOW EVERYONE `create` * - ALLOW OWNER `deleteById` * - ALLOW EVERYONE `login` * - ALLOW EVERYONE `logout` * - ALLOW OWNER `findById` * - ALLOW OWNER `updateAttributes` * * @property {String} username Must be unique. * @property {String} password Hidden from remote clients. * @property {String} email Must be valid email. * @property {Boolean} emailVerified Set when a user's email has been verified via `confirm()`. * @property {String} verificationToken Set when `verify()` is called. * @property {String} realm The namespace the user belongs to. See [Partitioning users with realms](http://loopback.io/doc/en/lb2/Partitioning-users-with-realms.html) for details. * @property {Object} settings Extends the `Model.settings` object. * @property {Boolean} settings.emailVerificationRequired Require the email verification * process before allowing a login. * @property {Number} settings.ttl Default time to live (in seconds) for the `AccessToken` created by `User.login() / user.createAccessToken()`. * Default is `1209600` (2 weeks) * @property {Number} settings.maxTTL The max value a user can request a token to be alive / valid for. * Default is `31556926` (1 year) * @property {Boolean} settings.realmRequired Require a realm when logging in a user. * @property {String} settings.realmDelimiter When set a realm is required. * @property {Number} settings.resetPasswordTokenTTL Time to live for password reset `AccessToken`. Default is `900` (15 minutes). * @property {Number} settings.saltWorkFactor The `bcrypt` salt work factor. Default is `10`. * @property {Boolean} settings.caseSensitiveEmail Enable case sensitive email. * * @class User * @inherits {PersistedModel} */ module.exports = function(User) { /** * Create access token for the logged in user. This method can be overridden to * customize how access tokens are generated * * Supported flavours: * * ```js * createAccessToken(ttl, cb) * createAccessToken(ttl, options, cb); * createAccessToken(options, cb); * // recent addition: * createAccessToken(data, options, cb); * ``` * * @options {Number|Object} [ttl|data] Either the requested ttl, * or an object with token properties to set (see below). * @property {Number} [ttl] The requested ttl * @property {String[]} [scopes] The access scopes granted to the token. * @param {Object} [options] Additional options including remoting context * @callback {Function} cb The callback function * @param {String|Error} err The error string or object * @param {AccessToken} token The generated access token object * @promise * */ User.prototype.createAccessToken = function(ttl, options, cb) { if (cb === undefined && typeof options === 'function') { // createAccessToken(ttl, cb) cb = options; options = undefined; } cb = cb || utils.createPromiseCallback(); let tokenData; if (typeof ttl !== 'object') { // createAccessToken(ttl[, options], cb) tokenData = {ttl}; } else if (options) { // createAccessToken(data, options, cb) tokenData = ttl; } else { // createAccessToken(options, cb); tokenData = {}; } var userSettings = this.constructor.settings; tokenData.ttl = Math.min(tokenData.ttl || userSettings.ttl, userSettings.maxTTL); this.accessTokens.create(tokenData, options, cb); return cb.promise; }; function splitPrincipal(name, realmDelimiter) { var parts = [null, name]; if (!realmDelimiter) { return parts; } var index = name.indexOf(realmDelimiter); if (index !== -1) { parts[0] = name.substring(0, index); parts[1] = name.substring(index + realmDelimiter.length); } return parts; } /** * Normalize the credentials * @param {Object} credentials The credential object * @param {Boolean} realmRequired * @param {String} realmDelimiter The realm delimiter, if not set, no realm is needed * @returns {Object} The normalized credential object */ User.normalizeCredentials = function(credentials, realmRequired, realmDelimiter) { var query = {}; credentials = credentials || {}; if (!realmRequired) { if (credentials.email) { query.email = credentials.email; } else if (credentials.username) { query.username = credentials.username; } } else { if (credentials.realm) { query.realm = credentials.realm; } var parts; if (credentials.email) { parts = splitPrincipal(credentials.email, realmDelimiter); query.email = parts[1]; if (parts[0]) { query.realm = parts[0]; } } else if (credentials.username) { parts = splitPrincipal(credentials.username, realmDelimiter); query.username = parts[1]; if (parts[0]) { query.realm = parts[0]; } } } return query; }; /** * Login a user by with the given `credentials`. * * ```js * User.login({username: 'foo', password: 'bar'}, function (err, token) { * console.log(token.id); * }); * ``` * * If the `emailVerificationRequired` flag is set for the inherited user model * and the email has not yet been verified then the method will return a 401 * error that will contain the user's id. This id can be used to call the * `api/verify` remote method to generate a new email verification token and * send back the related email to the user. * * @param {Object} credentials username/password or email/password * @param {String[]|String} [include] Optionally set it to "user" to include * the user info * @callback {Function} callback Callback function * @param {Error} err Error object * @param {AccessToken} token Access token if login is successful * @promise */ User.login = function(credentials, include, fn) { var self = this; if (typeof include === 'function') { fn = include; include = undefined; } fn = fn || utils.createPromiseCallback(); include = (include || ''); if (Array.isArray(include)) { include = include.map(function(val) { return val.toLowerCase(); }); } else { include = include.toLowerCase(); } var realmDelimiter; // Check if realm is required var realmRequired = !!(self.settings.realmRequired || self.settings.realmDelimiter); if (realmRequired) { realmDelimiter = self.settings.realmDelimiter; } var query = self.normalizeCredentials(credentials, realmRequired, realmDelimiter); if (realmRequired && !query.realm) { var err1 = new Error(g.f('{{realm}} is required')); err1.statusCode = 400; err1.code = 'REALM_REQUIRED'; fn(err1); return fn.promise; } if (!query.email && !query.username) { var err2 = new Error(g.f('{{username}} or {{email}} is required')); err2.statusCode = 400; err2.code = 'USERNAME_EMAIL_REQUIRED'; fn(err2); return fn.promise; } self.findOne({where: query}, function(err, user) { var defaultError = new Error(g.f('login failed')); defaultError.statusCode = 401; defaultError.code = 'LOGIN_FAILED'; function tokenHandler(err, token) { if (err) return fn(err); if (Array.isArray(include) ? include.indexOf('user') !== -1 : include === 'user') { // NOTE(bajtos) We can't set token.user here: // 1. token.user already exists, it's a function injected by // "AccessToken belongsTo User" relation // 2. ModelBaseClass.toJSON() ignores own properties, thus // the value won't be included in the HTTP response // See also loopback#161 and loopback#162 token.__data.user = user; } fn(err, token); } if (err) { debug('An error is reported from User.findOne: %j', err); fn(defaultError); } else if (user) { user.hasPassword(credentials.password, function(err, isMatch) { if (err) { debug('An error is reported from User.hasPassword: %j', err); fn(defaultError); } else if (isMatch) { if (self.settings.emailVerificationRequired && !user.emailVerified) { // Fail to log in if email verification is not done yet debug('User email has not been verified'); err = new Error(g.f('login failed as the email has not been verified')); err.statusCode = 401; err.code = 'LOGIN_FAILED_EMAIL_NOT_VERIFIED'; err.details = { userId: user.id, }; fn(err); } else { if (user.createAccessToken.length === 2) { user.createAccessToken(credentials.ttl, tokenHandler); } else { user.createAccessToken(credentials.ttl, credentials, tokenHandler); } } } else { debug('The password is invalid for user %s', query.email || query.username); fn(defaultError); } }); } else { debug('No matching record is found for user %s', query.email || query.username); fn(defaultError); } }); return fn.promise; }; /** * Logout a user with the given accessToken id. * * ```js * User.logout('asd0a9f8dsj9s0s3223mk', function (err) { * console.log(err || 'Logged out'); * }); * ``` * * @param {String} accessTokenID * @callback {Function} callback * @param {Error} err * @promise */ User.logout = function(tokenId, fn) { fn = fn || utils.createPromiseCallback(); var err; if (!tokenId) { err = new Error(g.f('{{accessToken}} is required to logout')); err.status = 401; process.nextTick(fn, err); return fn.promise; } this.relations.accessTokens.modelTo.destroyById(tokenId, function(err, info) { if (err) { fn(err); } else if ('count' in info && info.count === 0) { err = new Error(g.f('Could not find {{accessToken}}')); err.status = 401; fn(err); } else { fn(); } }); return fn.promise; }; User.observe('before delete', function(ctx, next) { var AccessToken = ctx.Model.relations.accessTokens.modelTo; var pkName = ctx.Model.definition.idName() || 'id'; ctx.Model.find({where: ctx.where, fields: [pkName]}, function(err, list) { if (err) return next(err); var ids = list.map(function(u) { return u[pkName]; }); ctx.where = {}; ctx.where[pkName] = {inq: ids}; AccessToken.destroyAll({userId: {inq: ids}}, next); }); }); /** * Compare the given `password` with the users hashed password. * * @param {String} password The plain text password * @callback {Function} callback Callback function * @param {Error} err Error object * @param {Boolean} isMatch Returns true if the given `password` matches record * @promise */ User.prototype.hasPassword = function(plain, fn) { fn = fn || utils.createPromiseCallback(); if (this.password && plain) { bcrypt.compare(plain, this.password, function(err, isMatch) { if (err) return fn(err); fn(null, isMatch); }); } else { fn(null, false); } return fn.promise; }; /** * Change this user's password. * * @param {*} userId Id of the user changing the password * @param {string} oldPassword Current password, required in order * to strongly verify the identity of the requesting user * @param {string} newPassword The new password to use. * @param {object} [options] * @callback {Function} callback * @param {Error} err Error object * @promise */ User.changePassword = function(userId, oldPassword, newPassword, options, cb) { if (cb === undefined && typeof options === 'function') { cb = options; options = undefined; } cb = cb || utils.createPromiseCallback(); // Make sure to use the constructor of the (sub)class // where the method is invoked from (`this` instead of `User`) this.findById(userId, options, (err, inst) => { if (err) return cb(err); if (!inst) { const err = new Error(`User ${userId} not found`); Object.assign(err, { code: 'USER_NOT_FOUND', statusCode: 401, }); return cb(err); } inst.changePassword(oldPassword, newPassword, options, cb); }); return cb.promise; }; /** * Change this user's password (prototype/instance version). * * @param {string} oldPassword Current password, required in order * to strongly verify the identity of the requesting user * @param {string} newPassword The new password to use. * @param {object} [options] * @callback {Function} callback * @param {Error} err Error object * @promise */ User.prototype.changePassword = function(oldPassword, newPassword, options, cb) { if (cb === undefined && typeof options === 'function') { cb = options; options = undefined; } cb = cb || utils.createPromiseCallback(); this.hasPassword(oldPassword, (err, isMatch) => { if (err) return cb(err); if (!isMatch) { const err = new Error('Invalid current password'); Object.assign(err, { code: 'INVALID_PASSWORD', statusCode: 400, }); return cb(err); } this.setPassword(newPassword, options, cb); }); return cb.promise; }; /** * Set this user's password after a password-reset request was made. * * @param {*} userId Id of the user changing the password * @param {string} newPassword The new password to use. * @param {Object} [options] Additional options including remoting context * @callback {Function} callback * @param {Error} err Error object * @promise */ User.setPassword = function(userId, newPassword, options, cb) { assert(userId != null && userId !== '', 'userId is a required argument'); assert(!!newPassword, 'newPassword is a required argument'); if (cb === undefined && typeof options === 'function') { cb = options; options = undefined; } cb = cb || utils.createPromiseCallback(); // Make sure to use the constructor of the (sub)class // where the method is invoked from (`this` instead of `User`) this.findById(userId, options, (err, inst) => { if (err) return cb(err); if (!inst) { const err = new Error(`User ${userId} not found`); Object.assign(err, { code: 'USER_NOT_FOUND', statusCode: 401, }); return cb(err); } inst.setPassword(newPassword, options, cb); }); return cb.promise; }; /** * Set this user's password. The callers of this method * must ensure the client making the request is authorized * to change the password, typically by providing the correct * current password or a password-reset token. * * @param {string} newPassword The new password to use. * @param {Object} [options] Additional options including remoting context * @callback {Function} callback * @param {Error} err Error object * @promise */ User.prototype.setPassword = function(newPassword, options, cb) { assert(!!newPassword, 'newPassword is a required argument'); if (cb === undefined && typeof options === 'function') { cb = options; options = undefined; } cb = cb || utils.createPromiseCallback(); try { this.constructor.validatePassword(newPassword); } catch (err) { cb(err); return cb.promise; } // We need to modify options passed to patchAttributes, but we don't want // to modify the original options object passed to us by setPassword caller options = Object.assign({}, options); // patchAttributes() does not allow callers to modify the password property // unless "options.setPassword" is set. options.setPassword = true; const delta = {password: newPassword}; this.patchAttributes(delta, options, (err, updated) => cb(err)); return cb.promise; }; /** * Returns default verification options to use when calling User.prototype.verify() * from remote method /user/:id/verify. * * NOTE: the User.getVerifyOptions() method can also be used to ease the * building of identity verification options. * * ```js * var verifyOptions = MyUser.getVerifyOptions(); * user.verify(verifyOptions); * ``` * * This is the full list of possible params, with example values * * ```js * { * type: 'email', * mailer: { * send(verifyOptions, options, cb) { * // send the email * cb(err, result); * } * }, * to: 'test@email.com', * from: 'noreply@email.com' * subject: 'verification email subject', * text: 'Please verify your email by opening this link in a web browser', * headers: {'Mime-Version': '1.0'}, * template: 'path/to/template.ejs', * templateFn: function(verifyOptions, options, cb) { * cb(null, 'some body template'); * } * redirect: '/', * verifyHref: 'http://localhost:3000/api/user/confirm', * host: 'localhost' * protocol: 'http' * port: 3000, * restApiRoot= '/api', * generateVerificationToken: function (user, options, cb) { * cb(null, 'random-token'); * } * } * ``` * * NOTE: param `to` internally defaults to user's email but can be overriden for * test purposes or advanced customization. * * Static default params can be modified in your custom user model json definition * using `settings.verifyOptions`. Any default param can be programmatically modified * like follows: * * ```js * customUserModel.getVerifyOptions = function() { * const base = MyUser.base.getVerifyOptions(); * return Object.assign({}, base, { * // custom values * }); * } * ``` * * Usually you should only require to modify a subset of these params * See `User.verify()` and `User.prototype.verify()` doc for params reference * and their default values. */ User.getVerifyOptions = function() { const verifyOptions = { type: 'email', from: 'noreply@example.com', }; return this.settings.verifyOptions || verifyOptions; }; /** * Verify a user's identity by sending them a confirmation message. * NOTE: Currently only email verification is supported * * ```js * var verifyOptions = { * type: 'email', * from: 'noreply@example.com' * template: 'verify.ejs', * redirect: '/', * generateVerificationToken: function (user, options, cb) { * cb('random-token'); * } * }; * * user.verify(verifyOptions); * ``` * * NOTE: the User.getVerifyOptions() method can also be used to ease the * building of identity verification options. * * ```js * var verifyOptions = MyUser.getVerifyOptions(); * user.verify(verifyOptions); * ``` * * @options {Object} verifyOptions * @property {String} type Must be `'email'` in the current implementation. * @property {Function} mailer A mailer function with a static `.send() method. * The `.send()` method must accept the verifyOptions object, the method's * remoting context options object and a callback function with `(err, email)` * as parameters. * Defaults to provided `userModel.email` function, or ultimately to LoopBack's * own mailer function. * @property {String} to Email address to which verification email is sent. * Defaults to user's email. Can also be overriden to a static value for test * purposes. * @property {String} from Sender email address * For example `'noreply@example.com'`. * @property {String} subject Subject line text. * Defaults to `'Thanks for Registering'` or a local equivalent. * @property {String} text Text of email. * Defaults to `'Please verify your email by opening this link in a web browser:` * followed by the verify link. * @property {Object} headers Email headers. None provided by default. * @property {String} template Relative path of template that displays verification * page. Defaults to `'../../templates/verify.ejs'`. * @property {Function} templateFn A function generating the email HTML body * from `verify()` options object and generated attributes like `options.verifyHref`. * It must accept the verifyOptions object, the method's remoting context options * object and a callback function with `(err, html)` as parameters. * A default templateFn function is provided, see `createVerificationEmailBody()` * for implementation details. * @property {String} redirect Page to which user will be redirected after * they verify their email. Defaults to `'/'`. * @property {String} verifyHref The link to include in the user's verify message. * Defaults to an url analog to: * `http://host:port/restApiRoot/userRestPath/confirm?uid=userId&redirect=/`` * @property {String} host The API host. Defaults to app's host or `localhost`. * @property {String} protocol The API protocol. Defaults to `'http'`. * @property {Number} port The API port. Defaults to app's port or `3000`. * @property {String} restApiRoot The API root path. Defaults to app's restApiRoot * or `'/api'` * @property {Function} generateVerificationToken A function to be used to * generate the verification token. * It must accept the verifyOptions object, the method's remoting context options * object and a callback function with `(err, hexStringBuffer)` as parameters. * This function should NOT add the token to the user object, instead simply * execute the callback with the token! User saving and email sending will be * handled in the `verify()` method. * A default token generation function is provided, see `generateVerificationToken()` * for implementation details. * @callback {Function} cb Callback function. * @param {Object} options remote context options. * @param {Error} err Error object. * @param {Object} object Contains email, token, uid. * @promise */ User.prototype.verify = function(verifyOptions, options, cb) { if (cb === undefined && typeof options === 'function') { cb = options; options = undefined; } cb = cb || utils.createPromiseCallback(); var user = this; var userModel = this.constructor; var registry = userModel.registry; // final assertion is performed once all options are assigned assert(typeof verifyOptions === 'object', 'verifyOptions object param required when calling user.verify()'); // Set a default template generation function if none provided verifyOptions.templateFn = verifyOptions.templateFn || createVerificationEmailBody; // Set a default token generation function if none provided verifyOptions.generateVerificationToken = verifyOptions.generateVerificationToken || User.generateVerificationToken; // Set a default mailer function if none provided verifyOptions.mailer = verifyOptions.mailer || userModel.email || registry.getModelByType(loopback.Email); var pkName = userModel.definition.idName() || 'id'; verifyOptions.redirect = verifyOptions.redirect || '/'; var defaultTemplate = path.join(__dirname, '..', '..', 'templates', 'verify.ejs'); verifyOptions.template = path.resolve(verifyOptions.template || defaultTemplate); verifyOptions.user = user; verifyOptions.protocol = verifyOptions.protocol || 'http'; var app = userModel.app; verifyOptions.host = verifyOptions.host || (app && app.get('host')) || 'localhost'; verifyOptions.port = verifyOptions.port || (app && app.get('port')) || 3000; verifyOptions.restApiRoot = verifyOptions.restApiRoot || (app && app.get('restApiRoot')) || '/api'; var displayPort = ( (verifyOptions.protocol === 'http' && verifyOptions.port == '80') || (verifyOptions.protocol === 'https' && verifyOptions.port == '443') ) ? '' : ':' + verifyOptions.port; var urlPath = joinUrlPath( verifyOptions.restApiRoot, userModel.http.path, userModel.sharedClass.findMethodByName('confirm').http.path ); verifyOptions.verifyHref = verifyOptions.verifyHref || verifyOptions.protocol + '://' + verifyOptions.host + displayPort + urlPath + '?' + qs.stringify({ uid: '' + verifyOptions.user[pkName], redirect: verifyOptions.redirect, }); verifyOptions.to = verifyOptions.to || user.email; verifyOptions.subject = verifyOptions.subject || g.f('Thanks for Registering'); verifyOptions.headers = verifyOptions.headers || {}; // assert the verifyOptions params that might have been badly defined assertVerifyOptions(verifyOptions); // argument "options" is passed depending on verifyOptions.generateVerificationToken function requirements var tokenGenerator = verifyOptions.generateVerificationToken; if (tokenGenerator.length == 3) { tokenGenerator(user, options, addTokenToUserAndSave); } else { tokenGenerator(user, addTokenToUserAndSave); } function addTokenToUserAndSave(err, token) { if (err) return cb(err); user.verificationToken = token; user.save(options, function(err) { if (err) return cb(err); sendEmail(user); }); } // TODO - support more verification types function sendEmail(user) { verifyOptions.verifyHref += '&token=' + user.verificationToken; verifyOptions.verificationToken = user.verificationToken; verifyOptions.text = verifyOptions.text || g.f('Please verify your email by opening ' + 'this link in a web browser:\n\t%s', verifyOptions.verifyHref); verifyOptions.text = verifyOptions.text.replace(/\{href\}/g, verifyOptions.verifyHref); // argument "options" is passed depending on templateFn function requirements var templateFn = verifyOptions.templateFn; if (templateFn.length == 3) { templateFn(verifyOptions, options, setHtmlContentAndSend); } else { templateFn(verifyOptions, setHtmlContentAndSend); } function setHtmlContentAndSend(err, html) { if (err) return cb(err); verifyOptions.html = html; // Remove verifyOptions.template to prevent rejection by certain // nodemailer transport plugins. delete verifyOptions.template; // argument "options" is passed depending on Email.send function requirements var Email = verifyOptions.mailer; if (Email.send.length == 3) { Email.send(verifyOptions, options, handleAfterSend); } else { Email.send(verifyOptions, handleAfterSend); } function handleAfterSend(err, email) { if (err) return cb(err); cb(null, {email: email, token: user.verificationToken, uid: user[pkName]}); } } } return cb.promise; }; function assertVerifyOptions(verifyOptions) { assert(verifyOptions.type, 'You must supply a verification type (verifyOptions.type)'); assert(verifyOptions.type === 'email', 'Unsupported verification type'); assert(verifyOptions.to, 'Must include verifyOptions.to when calling user.verify() ' + 'or the user must have an email property'); assert(verifyOptions.from, 'Must include verifyOptions.from when calling user.verify()'); assert(typeof verifyOptions.templateFn === 'function', 'templateFn must be a function'); assert(typeof verifyOptions.generateVerificationToken === 'function', 'generateVerificationToken must be a function'); assert(verifyOptions.mailer, 'A mailer function must be provided'); assert(typeof verifyOptions.mailer.send === 'function', 'mailer.send must be a function '); } function createVerificationEmailBody(verifyOptions, options, cb) { var template = loopback.template(verifyOptions.template); var body = template(verifyOptions); cb(null, body); } /** * A default verification token generator which accepts the user the token is * being generated for and a callback function to indicate completion. * This one uses the crypto library and 64 random bytes (converted to hex) * for the token. When used in combination with the user.verify() method this * function will be called with the `user` object as it's context (`this`). * * @param {object} user The User this token is being generated for. * @param {object} options remote context options. * @param {Function} cb The generator must pass back the new token with this function call. */ User.generateVerificationToken = function(user, options, cb) { crypto.randomBytes(64, function(err, buf) { cb(err, buf && buf.toString('hex')); }); }; /** * Confirm the user's identity. * * @param {Any} userId * @param {String} token The validation token * @param {String} redirect URL to redirect the user to once confirmed * @callback {Function} callback * @param {Error} err * @promise */ User.confirm = function(uid, token, redirect, fn) { fn = fn || utils.createPromiseCallback(); this.findById(uid, function(err, user) { if (err) { fn(err); } else { if (user && user.verificationToken === token) { user.verificationToken = null; user.emailVerified = true; user.save(function(err) { if (err) { fn(err); } else { fn(); } }); } else { if (user) { err = new Error(g.f('Invalid token: %s', token)); err.statusCode = 400; err.code = 'INVALID_TOKEN'; } else { err = new Error(g.f('User not found: %s', uid)); err.statusCode = 404; err.code = 'USER_NOT_FOUND'; } fn(err); } } }); return fn.promise; }; /** * Create a short lived access token for temporary login. Allows users * to change passwords if forgotten. * * @options {Object} options * @prop {String} email The user's email address * @property {String} realm The user's realm (optional) * @callback {Function} callback * @param {Error} err * @promise */ User.resetPassword = function(options, cb) { cb = cb || utils.createPromiseCallback(); var UserModel = this; var ttl = UserModel.settings.resetPasswordTokenTTL || DEFAULT_RESET_PW_TTL; options = options || {}; if (typeof options.email !== 'string') { var err = new Error(g.f('Email is required')); err.statusCode = 400; err.code = 'EMAIL_REQUIRED'; cb(err); return cb.promise; } try { if (options.password) { UserModel.validatePassword(options.password); } } catch (err) { return cb(err); } var where = { email: options.email, }; if (options.realm) { where.realm = options.realm; } UserModel.findOne({where: where}, function(err, user) { if (err) { return cb(err); } if (!user) { err = new Error(g.f('Email not found')); err.statusCode = 404; err.code = 'EMAIL_NOT_FOUND'; return cb(err); } // create a short lived access token for temp login to change password // TODO(ritch) - eventually this should only allow password change if (UserModel.settings.emailVerificationRequired && !user.emailVerified) { err = new Error(g.f('Email has not been verified')); err.statusCode = 401; err.code = 'RESET_FAILED_EMAIL_NOT_VERIFIED'; return cb(err); } if (UserModel.settings.restrictResetPasswordTokenScope) { const tokenData = { ttl: ttl, scopes: ['reset-password'], }; user.createAccessToken(tokenData, options, onTokenCreated); } else { // We need to preserve backwards-compatibility with // user-supplied implementations of "createAccessToken" // that may not support "options" argument (we have such // examples in our test suite). user.createAccessToken(ttl, onTokenCreated); } function onTokenCreated(err, accessToken) { if (err) { return cb(err); } cb(); UserModel.emit('resetPasswordRequest', { email: options.email, accessToken: accessToken, user: user, options: options, }); } }); return cb.promise; }; /*! * Hash the plain password */ User.hashPassword = function(plain) { this.validatePassword(plain); var salt = bcrypt.genSaltSync(this.settings.saltWorkFactor || SALT_WORK_FACTOR); return bcrypt.hashSync(plain, salt); }; User.validatePassword = function(plain) { var err; if (plain && typeof plain === 'string' && plain.length <= MAX_PASSWORD_LENGTH) { return true; } if (plain.length > MAX_PASSWORD_LENGTH) { err = new Error(g.f('Password too long: %s', plain)); err.code = 'PASSWORD_TOO_LONG'; } else { err = new Error(g.f('Invalid password: %s', plain)); err.code = 'INVALID_PASSWORD'; } err.statusCode = 422; throw err; }; User._invalidateAccessTokensOfUsers = function(userIds, options, cb) { if (typeof options === 'function' && cb === undefined) { cb = options; options = {}; } if (!Array.isArray(userIds) || !userIds.length) return process.nextTick(cb); var accessTokenRelation = this.relations.accessTokens; if (!accessTokenRelation) return process.nextTick(cb); var AccessToken = accessTokenRelation.modelTo; var query = {userId: {inq: userIds}}; var tokenPK = AccessToken.definition.idName() || 'id'; if (options.accessToken && tokenPK in options.accessToken) { query[tokenPK] = {neq: options.accessToken[tokenPK]}; } // add principalType in AccessToken.query if using polymorphic relations // between AccessToken and User var relatedUser = AccessToken.relations.user; var isRelationPolymorphic = relatedUser && relatedUser.polymorphic && !relatedUser.modelTo; if (isRelationPolymorphic) { query.principalType = this.modelName; } AccessToken.deleteAll(query, options, cb); }; /*! * Setup an extended user model. */ User.setup = function() { // We need to call the base class's setup method User.base.setup.call(this); var UserModel = this; // max ttl this.settings.maxTTL = this.settings.maxTTL || DEFAULT_MAX_TTL; this.settings.ttl = this.settings.ttl || DEFAULT_TTL; UserModel.setter.email = function(value) { if (!UserModel.settings.caseSensitiveEmail) { this.$email = value.toLowerCase(); } else { this.$email = value; } }; UserModel.setter.password = function(plain) { if (typeof plain !== 'string') { return; } if (plain.indexOf('$2a$') === 0 && plain.length === 60) { // The password is already hashed. It can be the case // when the instance is loaded from DB this.$password = plain; } else { this.$password = this.constructor.hashPassword(plain); } }; // Make sure emailVerified is not set by creation UserModel.beforeRemote('create', function(ctx, user, next) { var body = ctx.req.body; if (body && body.emailVerified) { body.emailVerified = false; } next(); }); UserModel.remoteMethod( 'login', { description: 'Login a user with username/email and password.', accepts: [ {arg: 'credentials', type: 'object', required: true, http: {source: 'body'}}, {arg: 'include', type: ['string'], http: {source: 'query'}, description: 'Related objects to include in the response. ' + 'See the description of return value for more details.'}, ], returns: { arg: 'accessToken', type: 'object', root: true, description: g.f('The response body contains properties of the {{AccessToken}} created on login.\n' + 'Depending on the value of `include` parameter, the body may contain ' + 'additional properties:\n\n' + ' - `user` - `U+007BUserU+007D` - Data of the currently logged in user. ' + '{{(`include=user`)}}\n\n'), }, http: {verb: 'post'}, } ); UserModel.remoteMethod( 'logout', { description: 'Logout a user with access token.', accepts: [ {arg: 'access_token', type: 'string', http: function(ctx) { var req = ctx && ctx.req; var accessToken = req && req.accessToken; var tokenID = accessToken ? accessToken.id : undefined; return tokenID; }, description: 'Do not supply this argument, it is automatically extracted ' + 'from request headers.', }, ], http: {verb: 'all'}, } ); UserModel.remoteMethod( 'prototype.verify', { description: 'Trigger user\'s identity verification with configured verifyOptions', accepts: [ {arg: 'verifyOptions', type: 'object', http: ctx => this.getVerifyOptions()}, {arg: 'options', type: 'object', http: 'optionsFromRequest'}, ], http: {verb: 'post'}, } ); UserModel.remoteMethod( 'confirm', { description: 'Confirm a user registration with identity verification token.', accepts: [ {arg: 'uid', type: 'string', required: true}, {arg: 'token', type: 'string', required: true}, {arg: 'redirect', type: 'string'}, ], http: {verb: 'get', path: '/confirm'}, } ); UserModel.remoteMethod( 'resetPassword', { description: 'Reset password for a user with email.', accepts: [ {arg: 'options', type: 'object', required: true, http: {source: 'body'}}, ], http: {verb: 'post', path: '/reset'}, } ); UserModel.remoteMethod( 'changePassword', { description: 'Change a user\'s password.', accepts: [ {arg: 'id', type: 'any', http: ctx => ctx.req.accessToken && ctx.req.accessToken.userId, }, {arg: 'oldPassword', type: 'string', required: true, http: {source: 'form'}}, {arg: 'newPassword', type: 'string', required: true, http: {source: 'form'}}, {arg: 'options', type: 'object', http: 'optionsFromRequest'}, ], http: {verb: 'POST', path: '/change-password'}, } ); const setPasswordScopes = UserModel.settings.restrictResetPasswordTokenScope ? ['reset-password'] : undefined; UserModel.remoteMethod( 'setPassword', { description: 'Reset user\'s password via a password-reset token.', accepts: [ {arg: 'id', type: 'any', http: ctx => ctx.req.accessToken && ctx.req.accessToken.userId, }, {arg: 'newPassword', type: 'string', required: true, http: {source: 'form'}}, {arg: 'options', type: 'object', http: 'optionsFromRequest'}, ], accessScopes: setPasswordScopes, http: {verb: 'POST', path: '/reset-password'}, } ); UserModel.afterRemote('confirm', function(ctx, inst, next) { if (ctx.args.redirect !== undefined) { if (!ctx.res) { return next(new Error(g.f('The transport does not support HTTP redirects.'))); } ctx.res.location(ctx.args.redirect); ctx.res.status(302); } next(); }); // default models assert(loopback.Email, 'Email model must be defined before User model'); UserModel.email = loopback.Email; assert(loopback.AccessToken, 'AccessToken model must be defined before User model'); UserModel.accessToken = loopback.AccessToken; UserModel.validate('email', emailValidator, { message: g.f('Must provide a valid email'), }); // Realm users validation if (UserModel.settings.realmRequired && UserModel.settings.realmDelimiter) { UserModel.validatesUniquenessOf('email', { message: 'Email already exists', scopedTo: ['realm'], }); UserModel.validatesUniquenessOf('username', { message: 'User already exists', scopedTo: ['realm'], }); } else { // Regular(Non-realm) users validation UserModel.validatesUniquenessOf('email', {message: 'Email already exists'}); UserModel.validatesUniquenessOf('username', {message: 'User already exists'}); } return UserModel; }; /*! * Setup the base user. */ User.setup(); // --- OPERATION HOOKS --- // // Important: Operation hooks are inherited by subclassed models, // therefore they must be registered outside of setup() function // Access token to normalize email credentials User.observe('access', function normalizeEmailCase(ctx, next) { if (!ctx.Model.settings.caseSensitiveEmail && ctx.query.where && ctx.query.where.email && typeof(ctx.query.where.email) === 'string') { ctx.query.where.email = ctx.query.where.email.toLowerCase(); } next(); }); User.observe('before save', function rejectInsecurePasswordChange(ctx, next) { const UserModel = ctx.Model; if (!UserModel.settings.rejectPasswordChangesViaPatchOrReplace) { // In legacy password flow, any DAO method can change the password return next(); } if (ctx.isNewInstance) { // The password can be always set when creating a new User instance return next(); } const data = ctx.data || ctx.instance; const isPasswordChange = 'password' in data; // This is the option set by `setPassword()` API // when calling `this.patchAttritubes()` to change user's password if (ctx.options.setPassword) { // Verify that only the password is changed and nothing more or less. if (Object.keys(data).length > 1 || !isPasswordChange) { // This is a programmer's error, use the default status code 500 return next(new Error( 'Invalid use of "options.setPassword". Only "password" can be ' + 'changed when using this option.')); } return next(); } if (!isPasswordChange) { return next(); } const err = new Error( 'Changing user password via patch/replace API is not allowed. ' + 'Use changePassword() or setPassword() instead.'); err.statusCode = 401; err.code = 'PASSWORD_CHANGE_NOT_ALLOWED'; next(err); }); User.observe('before save', function prepareForTokenInvalidation(ctx, next) { if (ctx.isNewInstance) return next(); if (!ctx.where && !ctx.instance) return next(); var pkName = ctx.Model.definition.idName() || 'id'; var where = ctx.where; if (!where) { where = {}; where[pkName] = ctx.instance[pkName]; } ctx.Model.find({where: where}, ctx.options, function(err, userInstances) { if (err) return next(err); ctx.hookState.originalUserData = userInstances.map(function(u) { var user = {}; user[pkName] = u[pkName]; user.email = u.email; user.password = u.password; return user; }); var emailChanged; if (ctx.instance) { emailChanged = ctx.instance.email !== ctx.hookState.originalUserData[0].email; if (emailChanged && ctx.Model.settings.emailVerificationRequired) { ctx.instance.emailVerified = false; } } else if (ctx.data.email) { emailChanged = ctx.hookState.originalUserData.some(function(data) { return data.email != ctx.data.email; }); if (emailChanged && ctx.Model.settings.emailVerificationRequired) { ctx.data.emailVerified = false; } } next(); }); }); User.observe('after save', function invalidateOtherTokens(ctx, next) { if (!ctx.instance && !ctx.data) return next(); if (!ctx.hookState.originalUserData) return next(); var pkName = ctx.Model.definition.idName() || 'id'; var newEmail = (ctx.instance || ctx.data).email; var newPassword = (ctx.instance || ctx.data).password; if (!newEmail && !newPassword) return next(); var userIdsToExpire = ctx.hookState.originalUserData.filter(function(u) { return (newEmail && u.email !== newEmail) || (newPassword && u.password !== newPassword); }).map(function(u) { return u[pkName]; }); ctx.Model._invalidateAccessTokensOfUsers(userIdsToExpire, ctx.options, next); }); }; function emailValidator(err, done) { var value = this.email; if (value == null) return; if (typeof value !== 'string') return err('string'); if (value === '') return; if (!isEmail.validate(value)) return err('email'); } function joinUrlPath(args) { var result = arguments[0]; for (var ix = 1; ix < arguments.length; ix++) { var next = arguments[ix]; result += result[result.length - 1] === '/' && next[0] === '/' ? next.slice(1) : next; } return result; }