const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const Wordlist = require('./wordlist'); const errors = require('../errors'); const uuid = require('uuid'); const UserModel = require('../models/user'); const USER_STATUS = require('../models/user').USER_STATUS; const USER_ROLES = require('../models/user').USER_ROLES; const ActionsService = require('./actions'); // In the event that the TALK_SESSION_SECRET is missing but we are testing, then // set the process.env.TALK_SESSION_SECRET. if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { process.env.TALK_SESSION_SECRET = 'keyboard cat'; } else if (!process.env.TALK_SESSION_SECRET) { throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); } const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; // SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run // through during the salting process. const SALT_ROUNDS = 10; // UsersService is the interface for the application to interact with the // UserModel through. module.exports = class UsersService { /** * Finds a user given their email address that we have for them in the system * and ensures that the retuned user matches the password passed in as well. * @param {string} email - email to look up the user by * @param {string} password - password to match against the found user * @param {Function} done [description] */ static findLocalUser(email, password) { if (!email || typeof email !== 'string') { return Promise.reject('email is required for findLocalUser'); } return UserModel.findOne({ profiles: { $elemMatch: { id: email.toLowerCase(), provider: 'local' } } }) .then((user) => { if (!user) { return false; } return new Promise((resolve, reject) => { bcrypt.compare(password, user.password, (err, res) => { if (err) { return reject(err); } if (!res) { return resolve(false); } return resolve(user); }); }); }); } /** * Merges two users together by taking all the profiles on a given user and * pushing them into the source user followed by deleting the destination user's * user account. This will not merge the roles associated with the source user. * @param {String} dstUserID id of the user to which is the target of the merge * @param {String} srcUserID id of the user to which is the source of the merge * @return {Promise} resolves when the users are merged */ static mergeUsers(dstUserID, srcUserID) { let srcUser, dstUser; return Promise .all([ UserModel.findOne({id: dstUserID}).exec(), UserModel.findOne({id: srcUserID}).exec() ]) .then((users) => { dstUser = users[0]; srcUser = users[1]; srcUser.profiles.forEach((profile) => { dstUser.profiles.push(profile); }); return srcUser.remove(); }) .then(() => dstUser.save()); } static castUsername(username) { return username.replace(/ /g, '_').replace(/[^a-zA-Z_]/g, ''); } /** * Finds a user given a social profile and if the user does not exist, creates * them. * @param {Object} profile - User social/external profile * @param {Function} done [description] */ static findOrCreateExternalUser({id, provider, displayName}) { return UserModel .findOne({ profiles: { $elemMatch: { id, provider } } }) .then((user) => { if (user) { return user; } let username = UsersService.castUsername(displayName); // The user was not found, lets create them! user = new UserModel({ username, lowercaseUsername: username.toLowerCase(), roles: [], profiles: [{id, provider}] }); return user.save(); }); } static changePassword(id, password) { return new Promise((resolve, reject) => { bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { if (err) { return reject(err); } resolve(hashedPassword); }); }) .then((hashedPassword) => { return UserModel.update({id}, { $inc: {__v: 1}, $set: { password: hashedPassword } }); }); } /** * Creates local users. * @param {Array} users Users to create * @return {Promise} Resolves with the users that were created */ static createLocalUsers(users) { return Promise.all(users.map((user) => { return UsersService .createLocalUser(user.email, user.password, user.username); })); } /** * Check the requested username for blocked words and special chars * @param {String} username word to be checked for profanity * @param {Boolean} checkAgainstWordlist enables cheching against the wordlist * @return {Promise} */ static isValidUsername(username, checkAgainstWordlist = true) { const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/; if (!username) { return Promise.reject(errors.ErrMissingUsername); } if (!onlyLettersNumbersUnderscore.test(username)) { return Promise.reject(errors.ErrSpecialChars); } if (checkAgainstWordlist) { // check for profanity return Wordlist.usernameCheck(username); } // No errors found! return Promise.resolve(username); } /** * Performs validations for the password. */ static isValidPassword(password) { if (!password) { return Promise.reject(errors.ErrMissingPassword); } if (password.length < 8) { return Promise.reject(errors.ErrPasswordTooShort); } return Promise.resolve(password); } /** * Creates the local user with a given email, password, and name. * @param {String} email email of the new user * @param {String} password plaintext password of the new user * @param {String} username name of the display user * @param {Function} done callback */ static createLocalUser(email, password, username) { if (!email) { return Promise.reject(errors.ErrMissingEmail); } email = email.toLowerCase().trim(); username = username.trim(); return Promise.all([ UsersService.isValidUsername(username), UsersService.isValidPassword(password) ]) .then(() => { // username is valid return new Promise((resolve, reject) => { bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { if (err) { return reject(err); } let user = new UserModel({ username, lowercaseUsername: username.toLowerCase(), password: hashedPassword, roles: [], profiles: [ { id: email, provider: 'local' } ] }); user.save((err) => { if (err) { if (err.code === 11000) { if (err.message.match('Username')) { return reject(errors.ErrUsernameTaken); } return reject(errors.ErrEmailTaken); } return reject(err); } return resolve(user); }); }); }); }); } /** * Disables a given user account. * @param {String} id id of a user * @param {Function} done callback after the operation is complete */ static disableUser(id) { return UserModel.update({ id: id }, { $set: { disabled: true } }); } /** * Enables a given user account. * @param {String} id id of a user * @param {Function} done callback after the operation is complete */ static enableUser(id) { return UserModel.update({ id: id }, { $set: { disabled: false } }); } /** * Adds a role to a user. * @param {String} id id of a user * @param {String} role role to add * @param {Function} done callback after the operation is complete */ static addRoleToUser(id, role) { // Check to see if the user role is in the allowable set of roles. if (USER_ROLES.indexOf(role) === -1) { // User role is not supported! Error out here. return Promise.reject(new Error(`role ${role} is not supported`)); } return UserModel.update({ id: id }, { $addToSet: { roles: role } }); } /** * Removes a role from a user. * @param {String} id id of a user * @param {String} role role to remove * @param {Function} done callback after the operation is complete */ static removeRoleFromUser(id, role) { // Check to see if the user role is in the allowable set of roles. if (USER_ROLES.indexOf(role) === -1) { // User role is not supported! Error out here. return Promise.reject(new Error(`role ${role} is not supported`)); } return UserModel.update({ id: id }, { $pull: { roles: role } }); } /** * Set status of a user. * @param {String} id id of a user * @param {String} status status to set * @param {Function} done callback after the operation is complete */ static setStatus(id, status) { // Check to see if the user status is in the allowable set of roles. if (USER_STATUS.indexOf(status) === -1) { // User status is not supported! Error out here. return Promise.reject(new Error(`status ${status} is not supported`)); } return UserModel.update({ id, status: { $ne: 'APPROVED' } }, { $set: { status } }); } /** * Finds a user with the id. * @param {String} id user id (uuid) */ static findById(id) { return UserModel.findOne({id}); } /** * Finds users in an array of ids. * @param {Array} ids array of user identifiers (uuid) */ static findByIdArray(ids) { return UserModel.find({ id: {$in: ids} }); } /** * Finds public user information by an array of ids. * @param {Array} ids array of user identifiers (uuid) */ static findPublicByIdArray(ids) { return UserModel.find({ id: {$in: ids} }, 'id username'); } /** * Creates a JWT from a user email. Only works for local accounts. * @param {String} email of the local user */ static createPasswordResetToken(email) { if (!email || typeof email !== 'string') { return Promise.reject('email is required when creating a JWT for resetting passord'); } email = email.toLowerCase(); return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) .then((user) => { if (!user) { // Since we don't want to reveal that the email does/doesn't exist // just go ahead and resolve the Promise with null and check in the // endpoint. return; } const payload = { jti: uuid.v4(), email, userId: user.id, version: user.__v }; return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { algorithm: 'HS256', expiresIn: '1d', subject: PASSWORD_RESET_JWT_SUBJECT }); }); } /** * Verifies that the token was indeed signed by the session secret. * @param {String} token JWT token from the client * @return {Promise} */ static verifyToken(token, options = {}) { return new Promise((resolve, reject) => { // Set the allowed algorithms. options.algorithms = ['HS256']; jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => { if (err) { return reject(err); } resolve(decoded); }); }); } /** * Verifies a jwt and returns the associated user. * @param {String} token the JSON Web Token to verify */ static verifyPasswordResetToken(token) { return UsersService .verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT }) // TODO: add search by __v as well .then((decoded) => UsersService.findById(decoded.userId)); } /** * Finds a user using a value which gets compared using a prefix match against * the user's email address and/or their username. * @param {String} value value to search by * @return {Promise} */ static search(value) { return UserModel.find({ $or: [ // Search by a prefix match on the username. { 'username': { $regex: new RegExp(`^${value}`), $options: 'i' } }, // Search by a prefix match on the email address. { 'profiles': { $elemMatch: { id: { $regex: new RegExp(`^${value}`), $options: 'i' }, provider: 'local' } } } ] }); } /** * Returns a count of the current users. * @return {Promise} */ static count() { return UserModel.count(); } /** * Returns all the users. * @return {Promise} */ static all() { return UserModel.find(); } /** * Updates the user's settings. * @return {Promise} */ static updateSettings(id, settings) { return UserModel.update({ id }, { $set: { settings } }); } /** * Add an action to the user. * @param {String} item_id identifier of the user (uuid) * @param {String} user_id user id of the action (uuid) * @param {String} action the new action to the user * @return {Promise} */ static addAction(item_id, user_id, action_type, metadata) { return ActionsService.insertUserAction({ item_id, item_type: 'users', user_id, action_type, metadata }); } /** * This creates a token based around confirming the local profile. * @param {String} userID The user id for the user that we are creating the * token for. * @param {String} email The email that we are needing to get confirmed. * @return {Promise} */ static createEmailConfirmToken(userID = null, email, referer = process.env.TALK_ROOT_URL) { if (!email || typeof email !== 'string') { return Promise.reject('email is required when creating a JWT for resetting passord'); } // Conform the email to lowercase. email = email.toLowerCase(); const tokenOptions = { jwtid: uuid.v4(), algorithm: 'HS256', expiresIn: '1d', subject: EMAIL_CONFIRM_JWT_SUBJECT }; let userPromise; if (!userID) { // If there is no userID, we're coming from the endpoint where a new user // is re-requesting a confirmation email and we don't know the userID. userPromise = UserModel.findOne({profiles: {$elemMatch: {id: email, provider: 'local'}}}); } else { userPromise = UsersService.findById(userID); } return userPromise.then((user) => { if (!user) { return Promise.reject(errors.ErrNotFound); } // Get the profile representing the local account. let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); // Ensure that the user email hasn't already been verified. if (profile && profile.metadata && profile.metadata.confirmed_at) { return Promise.reject(new Error('email address already confirmed')); } return jwt.sign({ email, referer, userID: user.id }, process.env.TALK_SESSION_SECRET, tokenOptions); }); } /** * This verifies that a given token was for the email confirmation and updates * that user's profile with a 'confirmed_at' parameter with the current date. * @param {String} token the token containing the email confirmation details * signed with our secret. * @return {Promise} */ static verifyEmailConfirmation(token) { return UsersService .verifyToken(token, { subject: EMAIL_CONFIRM_JWT_SUBJECT }) .then(({userID, email, referer}) => { return UsersService .confirmEmail(userID, email) .then(() => ({userID, email, referer})); }); } /** * Marks the email on the user as confirmed. */ static confirmEmail(id, email) { return UserModel .update({ id: id, profiles: { $elemMatch: { id: email, provider: 'local' } } }, { $set: { 'profiles.$.metadata.confirmed_at': new Date() } }); } /** * Returns all users with pending 'ADMIN'ation actions. * @return {Promise} */ static moderationQueue() { return UserModel.find({status: 'PENDING'}); } /** * Gives the user the ability to edit their username. * @param {String} id the id of the user to be toggled. * @param {Boolean} canEditName sets whether the user can edit their name. * @return {Promise} */ static toggleNameEdit(id, canEditName) { return UserModel.update({id}, { $set: {canEditName} }); } /** * Updates the user's username. * @param {String} id the id of the user to be enabled. * @param {String} username The new username for the user. * @return {Promise} */ static editName(id, username) { return UserModel.update({ id, canEditName: true }, { $set: { username: username, lowercaseUsername: username.toLowerCase(), canEditName: false, status: 'PENDING' } }).then((result) => { return result.nModified > 0 ? result : Promise.reject(errors.ErrPermissionUpdateUsername); }); } };