const mongoose = require('../services/mongoose'); const uuid = require('uuid'); const _ = require('lodash'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const Action = require('./action'); const Comment = require('./comment'); const Wordlist = require('../services/wordlist'); const errors = require('../errors'); 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; // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ 'admin', 'moderator' ]; // USER_STATUSES is the list of statuses that are permitted for the user status. const USER_STATUS = [ 'active', 'banned' ]; // 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'); } // ProfileSchema is the mongoose schema defined as the representation of a // User's profile stored in MongoDB. const ProfileSchema = new mongoose.Schema({ // ID provides the identifier for the user profile, in the case of a local // provider, the id would be an email, in the case of a social provider, // the id would be the foreign providers identifier. id: { type: String, required: true }, // Provider is simply the name attached to the authentication mode. In the // case of a locally provided profile, this will simply be `local`, or a // social provider which for Facebook would just be `facebook`. provider: { type: String, required: true }, // Metadata provides a place to put provider specific details. An example of // something that could be stored here is the `metadata.confirmed_at` could be // used by the `local` provider to indicate when the email address was // confirmed. metadata: { type: mongoose.Schema.Types.Mixed } }, { _id: false }); // UserSchema is the mongoose schema defined as the representation of a User in // MongoDB. const UserSchema = new mongoose.Schema({ // This ID represents the most unique identifier for a user, it is generated // when the user is created as a random uuid. id: { type: String, default: uuid.v4, unique: true, required: true }, // This is sourced from the social provider or set manually during user setup // and simply provides a name to display for the given user. displayName: { type: String, unique: true, required: true }, // This is true when the user account is disabled, no action should be // acknowledged when they are disabled. Logins are also prevented. disabled: Boolean, // This provides a source of identity proof for users who login using the // local provider. A local provider will be assumed for users who do not // have any social profiles. password: String, // Profiles describes the array of identities for a given user. Any one user // can have multiple profiles associated with them, including multiple email // addresses. profiles: [ProfileSchema], // Roles provides an array of roles (as strings) that is associated with a // user. roles: [String], // Status provides a string that says in which state the account is. // When the account is banned, the user login is disabled. status: {type: String, enum: USER_STATUS, default: 'active'}, // User's settings settings: { bio: { type: String, default: '' } } }, { // This will ensure that we have proper timestamps available on this model. timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } }); // Add the indixies on the user profile data. UserSchema.index({ 'profiles.id': 1, 'profiles.provider': 1 }, { unique: true, background: false }); /** * toJSON overrides to remove the password field from the json * output. */ UserSchema.options.toJSON = {}; UserSchema.options.toJSON.hide = '_id password'; UserSchema.options.toJSON.virtuals = true; UserSchema.options.toJSON.transform = (doc, ret, options) => { if (options.hide) { options.hide.split(' ').forEach((prop) => { delete ret[prop]; }); } return ret; }; /** * Filters the object for the given user only allowing those with the allowed * roles/permissions to access particular parameters. */ UserSchema.method('filterForUser', function(user = false) { if (!user || !user.roles.includes('admin')) { let allowed = ['id', 'displayName', 'settings', 'created_at', 'updated_at']; if (user && user.id === this.id) { allowed.push('roles'); } return _.pick(this.toJSON(), allowed); } return this.toJSON(); }); /** * Returns true if the user has all the roles specified. */ UserSchema.method('hasRoles', function(...roles) { return roles.every((role) => this.roles.indexOf(role) >= 0); }); // Create the User model. const UserModel = mongoose.model('User', UserSchema); // UserService is the interface for the application to interact with the // UserModel through. const UserService = module.exports = {}; /** * 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] */ UserService.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 */ UserService.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()); }; /** * 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] */ UserService.findOrCreateExternalUser = (profile) => { return UserModel .findOne({ profiles: { $elemMatch: { id: profile.id, provider: profile.provider } } }) .then((user) => { if (user) { return user; } // The user was not found, lets create them! user = new UserModel({ displayName: profile.displayName, roles: [], profiles: [ { id: profile.id, provider: profile.provider } ] }); return user.save(); }); }; UserService.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 */ UserService.createLocalUsers = (users) => { return Promise.all(users.map((user) => { return UserService .createLocalUser(user.email, user.password, user.displayName); })); }; /** * Check the requested displayname for naughty words (currently in English) and special chars * @param {String} displayName word to be checked for profanity * @return {Promise} rejected if the machine's sensibilites are offended */ const isValidDisplayName = (displayName) => { const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/; if (!displayName) { return Promise.reject(errors.ErrMissingDisplay); } if (!onlyLettersNumbersUnderscore.test(displayName)) { return Promise.reject(errors.ErrSpecialChars); } // check for profanity return Wordlist.displayNameCheck(displayName); }; /** * 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} displayName name of the display user * @param {Function} done callback */ UserService.createLocalUser = (email, password, displayName) => { if (!email) { return Promise.reject(errors.ErrMissingEmail); } email = email.toLowerCase().trim(); displayName = displayName.toLowerCase().trim(); if (!password) { return Promise.reject(errors.ErrMissingPassword); } if (password.length < 8) { return Promise.reject(errors.ErrPasswordTooShort); } return isValidDisplayName(displayName) .then(() => { // displayName is valid return new Promise((resolve, reject) => { bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { if (err) { return reject(err); } let user = new UserModel({ displayName: displayName, password: hashedPassword, roles: [], profiles: [ { id: email, provider: 'local' } ] }); user.save((err) => { if (err) { if (err.code === 11000) { if (err.message.match('displayName')) { return reject(errors.ErrDisplayTaken); } 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 */ UserService.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 */ UserService.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 */ UserService.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 */ UserService.removeRoleFromUser = (id, role) => { 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 {String} comment_id id of the comment that the user was ban for. * @param {Function} done callback after the operation is complete */ UserService.setStatus = (id, status, comment_id) => { // 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`)); } // If ban then reject the comment and update status if (status === 'banned') { return UserModel.update({ id: id }, { $set: { status: status } }) .then(() => { return Comment.pushStatus(comment_id, 'rejected', id); }); } if (status === 'active') { return UserModel.update({ id: id }, { $set: { status: status } }); } }; /** * Finds a user with the id. * @param {String} id user id (uuid) */ UserService.findById = (id) => { return UserModel.findOne({id}); }; /** * Finds users in an array of ids. * @param {Array} ids array of user identifiers (uuid) */ UserService.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) */ UserService.findPublicByIdArray = (ids) => { return UserModel.find({ 'id': {$in: ids} }, 'id displayName'); }; /** * Creates a JWT from a user email. Only works for local accounts. * @param {String} email of the local user */ UserService.createPasswordResetToken = function (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} */ UserService.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 */ UserService.verifyPasswordResetToken = (token) => { return UserService .verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT }) .then((decoded) => UserService.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 display name. * @param {String} value value to search by * @return {Promise} */ UserService.search = (value) => { return UserModel.find({ $or: [ // Search by a prefix match on the displayName. { 'displayName': { $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} */ UserService.count = () => UserModel.count(); /** * Returns all the users. * @return {Promise} */ UserService.all = () => UserModel.find(); /** * Updates the user's settings. * @return {Promise} */ UserService.updateSettings = (id, settings) => 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} */ UserService.addAction = (item_id, user_id, action_type, metadata) => Action.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} */ UserService.createEmailConfirmToken = (userID, email) => { if (!email || typeof email !== 'string') { return Promise.reject('email is required when creating a JWT for resetting passord'); } email = email.toLowerCase(); return UserService .findById(userID) .then((user) => { if (!user) { return Promise.reject(new Error('user not found')); } // 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')); } const payload = { email, userID }; return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { jwtid: uuid.v4(), algorithm: 'HS256', expiresIn: '1d', subject: EMAIL_CONFIRM_JWT_SUBJECT }); }); }; /** * 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} */ UserService.verifyEmailConfirmation = (token) => { return UserService .verifyToken(token, { subject: EMAIL_CONFIRM_JWT_SUBJECT }) .then(({userID, email}) => { return UserModel .update({ id: userID, profiles: { $elemMatch: { id: email, provider: 'local' } } }, { $set: { 'profiles.$.metadata.confirmed_at': new Date() } }); }); };