Files
talk/models/user.js
T
2016-12-13 11:29:48 -07:00

616 lines
15 KiB
JavaScript

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');
// 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');
}
// 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: String,
// 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: [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
}
}, {
_id: false
})],
// 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')) {
return _.pick(this.toJSON(), ['id', 'displayName', 'settings', 'created_at', 'updated_at']);
}
return this.toJSON();
});
// 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);
}));
};
/**
* 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('email is required');
}
email = email.toLowerCase();
if (!password) {
return Promise.reject('password is required');
}
if (!displayName) {
return Promise.reject('displayName is required');
}
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) {
return reject('Email address already in use');
}
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 === null) {
// 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 Promise.resolve(null);
}
const payload = {email, jti: uuid.v4(), userId: user.id, version: user.__v};
const token = jwt.sign(payload, process.env.TALK_SESSION_SECRET, {expiresIn: '1d'});
return token;
});
};
/**
* verifies a jwt and returns the associated user
* @param {String} token the JSON Web Token to verify
*/
UserService.verifyPasswordResetToken = token => {
return new Promise((resolve, reject) => {
jwt.verify(token, process.env.TALK_SESSION_SECRET, (error, decoded) => {
if (error) {
return reject(error);
}
resolve(decoded);
});
})
.then(decoded => {
/**
* TODO: check the jti from this decoded token in redis
* and make an entry if it does not exist.
* reject if entry already exists.
*/
return 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 = () => {
return UserModel.count();
};
/**
* Returns all the users.
* @return {Promise}
*/
UserService.all = () => {
return UserModel.find();
};
/**
* Adds a new User bio
* @return {Promise}
*/
UserService.addBio = (id, bio) => (
UserModel.findOneAndUpdate({
id
}, {
$set: {
'settings.bio': bio
}
}, {
new: true
})
);
/**
* 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, field, detail) => Action.insertUserAction({
item_id,
item_type: 'users',
user_id,
action_type,
field,
detail
});