Files
talk/services/users.js
T
Belen Curcio 63a35cbceb Merge issues
2017-06-07 16:38:36 -03:00

957 lines
25 KiB
JavaScript

const assert = require('assert');
const uuid = require('uuid');
const bcrypt = require('bcrypt');
const url = require('url');
const jwt = require('jsonwebtoken');
const Wordlist = require('./wordlist');
const errors = require('../errors');
const {
JWT_SECRET,
ROOT_URL
} = require('../config');
const debug = require('debug')('talk:services:users');
const redis = require('./redis');
const redisClient = redis.createClient();
const UserModel = require('../models/user');
const USER_STATUS = require('../models/enum/user_status');
const USER_ROLES = require('../models/enum/user_roles');
const RECAPTCHA_WINDOW_SECONDS = 60 * 10; // 10 minutes.
const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required.
const SettingsService = require('./settings');
const ActionsService = require('./actions');
const MailerService = require('./mailer');
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 {
/**
* Returns a user (if found) for the given email address.
*/
static findLocalUser(email) {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required for findLocalUser');
}
return UserModel.findOne({
profiles: {
$elemMatch: {
id: email.toLowerCase(),
provider: 'local'
}
}
});
}
/**
* This records an unsucesfull login attempt for the given email address. If
* the maximum has been reached, the promise will be rejected with:
*
* errors.ErrLoginAttemptMaximumExceeded
*
* Indicating that the account should be flagged as "login recaptcha required"
* where future login attempts must be made with the recaptcha flag.
*/
static recordLoginAttempt(email) {
const rdskey = `la[${email.toLowerCase().trim()}]`;
return new Promise((resolve, reject) => {
redisClient
.multi()
.incr(rdskey)
.expire(rdskey, RECAPTCHA_WINDOW_SECONDS)
.exec((err, replies) => {
if (err) {
return reject(err);
}
// if this is new or has no expiry
if (replies[0] === 1 || replies[1] === -1) {
// then expire it after the timeout
redisClient.expire(rdskey, RECAPTCHA_WINDOW_SECONDS);
}
if (replies[0] >= RECAPTCHA_INCORRECT_TRIGGER) {
return reject(errors.ErrLoginAttemptMaximumExceeded);
}
resolve();
});
});
}
/**
* This checks to see if the current login attempts against a user exeeds the
* maximum value allowed, if so, it rejects with:
*
* errors.ErrLoginAttemptMaximumExceeded
*/
static checkLoginAttempts(email) {
const rdskey = `la[${email.toLowerCase().trim()}]`;
return new Promise((resolve, reject) => {
redisClient
.get(rdskey, (err, reply) => {
if (err) {
return reject(err);
}
if (!reply) {
return resolve();
}
if (reply >= RECAPTCHA_INCORRECT_TRIGGER) {
return reject(errors.ErrLoginAttemptMaximumExceeded);
}
resolve();
});
});
}
/**
* Sets or unsets the recaptcha_required flag on a user's local profile.
*/
static flagForRecaptchaRequirement(email, required) {
return UserModel.update({
profiles: {
$elemMatch: {
id: email.toLowerCase(),
provider: 'local'
}
}
}, {
$set: {
'profiles.$.metadata.recaptcha_required': required
}
});
}
/**
* 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;
}
// User does not exist and need to be created.
let username = UsersService.castUsername(displayName);
// The user was not found, lets create them!
user = new UserModel({
username,
lowercaseUsername: username.toLowerCase(),
roles: [],
profiles: [{id, provider}],
canEditName: true
});
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 async isValidUsername(username, checkAgainstWordlist = true) {
const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/;
if (!username) {
throw errors.ErrMissingUsername;
}
if (!onlyLettersNumbersUnderscore.test(username)) {
throw errors.ErrSpecialChars;
}
if (checkAgainstWordlist) {
// check for profanity
let err = await Wordlist.usernameCheck(username);
if (err) {
throw err;
}
}
// No errors found!
return 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) {
const roles = [];
// Check to see if the user role is in the allowable set of roles.
if (role && USER_ROLES.indexOf(role) === -1) {
// User role is not supported! Error out here.
return Promise.reject(new Error(`role ${role} is not supported`));
} else if(role) {
roles.push(role);
}
return UserModel.update({id}, {$set: {roles}});
}
/**
* 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
}
});
}
/**
* Suspend a user until specified time.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static suspendUser(id, message, until) {
return UserModel.findOneAndUpdate(
{id}, {
$set: {
suspension: {
until,
},
}
})
.then((user) => {
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Your account has been suspended',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
return MailerService.sendSimple(options);
}
}
});
}
/**
* Reject username. It changes the status to BANNED and canEditName to True.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static rejectUsername(id, message) {
return UserModel.findOneAndUpdate({
id
}, {
$set: {
status: 'BANNED',
canEditName: true,
}
})
.then((user) => {
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Email Suspension',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
return MailerService.sendSimple(options);
}
}
});
}
/**
* Finds a user with the id.
* @param {String} id user id (uuid)
*/
static findById(id) {
return UserModel.findOne({id});
}
/**
*
* @param {String} id the id of the current user
* @param {Object} token a jwt token used to sign in the user
*/
static async findOrCreateByIDToken(id, token) {
// Try to get the user.
let user = await UserModel.findOne({
id
});
// If the user was not found, try to look it up.
if (user === null) {
// If the user wasn't found, it will return null and the variable will be
// unchanged.
user = await lookupUserNotFound(token);
}
return user;
}
/**
* 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, loc) {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required when creating a JWT for resetting passord');
}
email = email.toLowerCase();
return Promise.all([
UserModel.findOne({profiles: {$elemMatch: {id: email}}}),
SettingsService.retrieve()
])
.then(([user, settings]) => {
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;
}
let redirectDomain;
try {
const {hostname, port} = url.parse(loc);
redirectDomain = hostname;
if (port) {
redirectDomain += `:${port}`;
}
} catch (e) {
return Promise.reject('redirect location is invalid');
}
if (settings.domains.whitelist.indexOf(redirectDomain) === -1) {
return Promise.reject('redirect location is not on the list of acceptable domains');
}
const payload = {
jti: uuid.v4(),
email,
loc,
userId: user.id,
version: user.__v
};
return jwt.sign(payload, JWT_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, JWT_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) => {
return Promise.all([UsersService.findById(decoded.userId), decoded.loc]);
});
}
/**
* 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 = 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
}, JWT_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) => {
if (result.nModified <= 0) {
return Promise.reject(errors.ErrPermissionUpdateUsername);
}
return result;
})
.catch((err) => {
if (err.code === 11000) {
throw errors.ErrUsernameTaken;
}
throw err;
});
}
/**
* Ignore another user
* @param {String} userId the id of the user that is ignoring another users
* @param {String[]} usersToIgnore Array of user IDs to ignore
*/
static ignoreUsers(userId, usersToIgnore) {
assert(Array.isArray(usersToIgnore), 'usersToIgnore is an array');
assert(usersToIgnore.every((u) => typeof u === 'string'), 'usersToIgnore is an array of string user IDs');
if (usersToIgnore.includes(userId)) {
throw new Error('Users cannot ignore themselves');
}
// TODO: For each usersToIgnore, make sure they exist?
return UserModel.update({id: userId}, {
$addToSet: {
ignoresUsers: {
$each: usersToIgnore
}
}
});
}
/**
* Stop ignoring other users
* @param {String} userId the id of the user that is ignoring another users
* @param {String[]} usersToStopIgnoring Array of user IDs to stop ignoring
*/
static async stopIgnoringUsers(userId, usersToStopIgnoring) {
assert(Array.isArray(usersToStopIgnoring), 'usersToStopIgnoring is an array');
assert(usersToStopIgnoring.every((u) => typeof u === 'string'), 'usersToStopIgnoring is an array of string user IDs');
await UserModel.update({id: userId}, {
$pullAll: {
ignoresUsers: usersToStopIgnoring
}
});
}
};
// Extract all the tokenUserNotFound plugins so we can integrate with other
// providers.
let tokenUserNotFoundHooks = null;
// Provide a function that can loop over the hooks and search for a provider
// can crack the token to a user.
const lookupUserNotFound = async (token) => {
if (!Array.isArray(tokenUserNotFoundHooks)) {
tokenUserNotFoundHooks = require('./plugins')
.get('server', 'tokenUserNotFound')
.map(({plugin, tokenUserNotFound}) => {
debug(`added plugin '${plugin.name}' to tokenUserNotFound hooks`);
return tokenUserNotFound;
});
}
for (let hook of tokenUserNotFoundHooks) {
let user = await hook(token);
if (user !== null && typeof user !== 'undefined') {
return user;
}
}
return null;
};