Files
talk/services/users.js
T
2017-11-09 16:07:46 -07:00

997 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const uuid = require('uuid');
const bcrypt = require('bcryptjs');
const errors = require('../errors');
const some = require('lodash/some');
const merge = require('lodash/merge');
const events = require('./events');
const timeago = require('./timeago');
const {
USERS_SUSPENSION_CHANGE,
USERS_BAN_CHANGE,
USERS_USERNAME_STATUS_CHANGE,
} = require('./events/constants');
const {
ROOT_URL
} = require('../config');
const {
jwt: JWT_SECRET
} = require('../secrets');
const debug = require('debug')('talk:services:users');
const UserModel = require('../models/user');
const USER_ROLES = require('../models/enum/user_roles');
const RECAPTCHA_WINDOW = '10m'; // 10 minutes.
const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required.
const ActionsService = require('./actions');
const MailerService = require('./mailer');
const i18n = require('./i18n');
const Wordlist = require('./wordlist');
const DomainList = require('./domain_list');
const SettingsService = require('./settings');
const {escapeRegExp} = require('./regex');
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;
// Create a redis client to use for authentication.
const Limit = require('./limit');
const loginRateLimiter = new Limit('loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, RECAPTCHA_WINDOW);
// UsersService is the interface for the application to interact with the
// UserModel through.
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 unsuccessful 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 async recordLoginAttempt(email) {
try {
await loginRateLimiter.test(email.toLowerCase().trim());
} catch (err) {
if (err === errors.ErrMaxRateLimit) {
throw errors.ErrLoginAttemptMaximumExceeded;
}
throw err;
}
}
static async setSuspensionStatus(id, until, assignedBy = null) {
let user = await UserModel.findOneAndUpdate({id}, {
$set: {
'status.suspension.until': until
},
$push: {
'status.suspension.history': {
until,
assigned_by: assignedBy,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
throw errors.ErrNotFound;
}
if (
user.status.suspension.until === until ||
(
user.status.suspension.until.getTime() > until.getTime() - 1000 &&
user.status.suspension.until.getTime() < until.getTime() + 1000
)
) {
return user;
}
throw new Error('suspension status change edit failed for an unknown reason');
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_SUSPENSION_CHANGE, user, until);
return user;
}
static async setBanStatus(id, status, assignedBy = null) {
let user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: status
}
}, {
$set: {
'status.banned.status': status
},
$push: {
'status.banned.history': {
status,
assigned_by: assignedBy,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
throw errors.ErrNotFound;
}
if (user.status.banned.status === status) {
return user;
}
throw new Error('ban status change edit failed for an unknown reason');
}
// Emit that the user ban status was changed.
await events.emitAsync(USERS_BAN_CHANGE, user, status);
return user;
}
static async setUsernameStatus(id, status, assignedBy = null) {
let user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: status
}
}, {
$set: {
'status.username.status': status
},
$push: {
'status.username.history': {
status,
assigned_by: assignedBy,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
throw errors.ErrNotFound;
}
if (user.status.username.status === status) {
return user;
}
throw new Error('username status change edit failed for an unknown reason');
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, status);
return user;
}
static async _setUsername(id, username, fromStatus, toStatus, resetAllowed = false) {
try {
const query = {
id,
'status.username.status': fromStatus
};
if (!resetAllowed) {
query.username = {$ne: username};
}
let user = await UserModel.findOneAndUpdate(query, {
$set: {
username,
lowercaseUsername: username.toLowerCase(),
'status.username.status': toStatus,
},
$push: {
'status.username.history': {
status: toStatus,
assigned_by: id,
created_at: Date.now()
}
}
}, {
new: true
});
if (!user) {
user = await UsersService.findById(id);
if (user === null) {
throw errors.ErrNotFound;
}
if (user.status.username.status !== fromStatus) {
throw errors.ErrPermissionUpdateUsername;
}
if (!resetAllowed && user.username === username) {
throw errors.ErrSameUsernameProvided;
}
throw new Error('edit username failed for an unexpected reason');
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, toStatus);
return user;
} catch (err) {
if (err.code === 11000) {
throw errors.ErrUsernameTaken;
}
throw err;
}
}
static async setUsername(id, username) {
return UsersService._setUsername(id, username, 'UNSET', 'SET', true);
}
static async changeUsername(id, username) {
return UsersService._setUsername(id, username, 'REJECTED', 'CHANGED');
}
/**
* This checks to see if the current login attempts against a user exceeds the
* maximum value allowed, if so, it rejects with:
*
* errors.ErrLoginAttemptMaximumExceeded
*/
static async checkLoginAttempts(email) {
const attempts = await loginRateLimiter.get(email.toLowerCase().trim());
if (!attempts) {
return;
}
if (attempts >= RECAPTCHA_INCORRECT_TRIGGER) {
throw errors.ErrLoginAttemptMaximumExceeded;
}
}
/**
* 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}],
status: {
username: {
status: 'UNSET',
history: {
status: 'UNSET'
}
}
}
});
return user.save();
});
}
/**
* sendEmailConfirmation sends a confirmation email to the user.
* @param {String} user the user to send the email to
* @param {String} email the email for the user to send the email to
*/
static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) {
let token = await UsersService.createEmailConfirmToken(user, email, redirectURI);
return MailerService.sendSimple({
template: 'email-confirm',
locals: {
token,
rootURL: ROOT_URL,
email
},
subject: i18n.t('email.confirm.subject'),
to: email
});
}
static async sendEmail(user, options) {
const localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (!localProfile) {
throw new Error('user does not have an email');
}
const {id: to} = localProfile;
options = merge(options, {
to,
});
return MailerService.sendSimple(options);
}
static async changePassword(id, password) {
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
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 async createLocalUser(email, password, username) {
if (!email) {
throw errors.ErrMissingEmail;
}
email = email.toLowerCase().trim();
username = username.trim();
await Promise.all([
UsersService.isValidUsername(username),
UsersService.isValidPassword(password)
]);
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
let user = new UserModel({
username,
lowercaseUsername: username.toLowerCase(),
password: hashedPassword,
roles: [],
profiles: [
{
id: email,
provider: 'local'
}
],
status: {
username: {
status: 'SET',
history: {
status: 'SET'
}
}
}
});
try {
user = await user.save();
} catch (err) {
if (err.code === 11000) {
if (err.message.match('Username')) {
throw errors.ErrUsernameTaken;
}
throw errors.ErrEmailTaken;
}
throw err;
}
return user;
}
/**
* 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 async 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.
throw 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 async 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.
throw new Error(`role ${role} is not supported`);
}
return UserModel.update({id}, {
$pull: {
roles: role
}
});
}
/**
* 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 async createPasswordResetToken(email, loc) {
if (!email || typeof email !== 'string') {
throw new Error('email is required when creating a JWT for resetting passord');
}
email = email.toLowerCase();
const [user, domainValidated] = await Promise.all([
UserModel.findOne({profiles: {$elemMatch: {id: email}}}),
DomainList.urlCheck(loc),
]);
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;
}
// If the domain didn't match any of the whitelisted domains and if it
// didn't match the mount domain, then throw an error.
if (!domainValidated && !DomainList.matchMount(loc)) {
throw new Error('user supplied location exists on non-permitted domain');
}
const payload = {
jti: uuid.v4(),
email,
loc,
userId: user.id,
version: user.__v
};
return JWT_SECRET.sign(payload, {
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) => {
JWT_SECRET.verify(token, 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 async verifyPasswordResetToken(token) {
const {userId, loc, version} = await UsersService.verifyToken(token, {
subject: PASSWORD_RESET_JWT_SUBJECT
});
const user = await UsersService.findById(userId);
if (version !== user.__v) {
throw new Error('password reset token has expired');
}
return [user, 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) {
if (!value || typeof value !== 'string' || value.length === 0) {
return UserModel.find({});
}
value = escapeRegExp(value);
return UserModel.find({
$or: [
// Search by a prefix match on the username.
{
'lowercaseUsername': {
$regex: new RegExp(value.toLowerCase())
}
},
// 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(query = {}) {
return UserModel.count(query);
}
/**
* 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.create({
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 async createEmailConfirmToken(user, email, referer = ROOT_URL) {
if (!email || typeof email !== 'string') {
throw new Error('email is required when creating a JWT for resetting password');
}
// Conform the email to lowercase.
email = email.toLowerCase();
const tokenOptions = {
jwtid: uuid.v4(),
expiresIn: '1d',
subject: EMAIL_CONFIRM_JWT_SUBJECT
};
// 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) {
throw new Error('email address already confirmed');
}
return JWT_SECRET.sign({
email,
referer,
userID: user.id
}, 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 async verifyEmailConfirmation(token) {
let {userID, email, referer} = await UsersService.verifyToken(token, {
subject: EMAIL_CONFIRM_JWT_SUBJECT
});
await UsersService.confirmEmail(userID, email);
return {userID, email, referer};
}
/**
* Marks the email on the user as confirmed.
*/
static confirmEmail(id, email) {
return UserModel
.update({
id,
profiles: {
$elemMatch: {
id: email,
provider: 'local'
}
}
}, {
$set: {
'profiles.$.metadata.confirmed_at': new Date()
}
});
}
/**
* Ignore another user
* @param {String} id the id of the user that is ignoring another users
* @param {Array<String>} usersToIgnore Array of user IDs to ignore
*/
static async ignoreUsers(id, usersToIgnore) {
if (usersToIgnore.includes(id)) {
throw new Error('Users cannot ignore themselves');
}
const users = await UsersService.findByIdArray(usersToIgnore);
if (some(users, (user) => user.isStaff())) {
throw errors.ErrCannotIgnoreStaff;
}
return UserModel.update({id}, {
$addToSet: {
ignoresUsers: {
$each: usersToIgnore
}
}
});
}
/**
* Stop ignoring other users
* @param {String} userId the id of the user that is ignoring another users
* @param {Array<String>} usersToStopIgnoring Array of user IDs to stop ignoring
*/
static async stopIgnoringUsers(id, usersToStopIgnoring) {
await UserModel.update({id}, {
$pullAll: {
ignoresUsers: usersToStopIgnoring
}
});
}
}
module.exports = UsersService;
events.on(USERS_BAN_CHANGE, async (user, status) => {
// Check to see if the user was banned now and is currently banned.
if (user.banned && status) {
await UsersService.sendEmail(user, {
template: 'banned',
locals: {
body: 'In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.'
},
subject: 'Your account has been banned',
});
}
});
events.on(USERS_SUSPENSION_CHANGE, async (user, until) => {
// Check to see if the user was suspended now and is currently suspended.
if (user.suspended && until !== null && until > Date.now()) {
const {organizationName} = await SettingsService.retrieve();
const message = i18n.t(
'suspenduser.email_message_suspend',
user.username,
organizationName,
timeago(until),
);
await UsersService.sendEmail(user, {
template: 'suspension',
locals: {
body: message,
},
subject: 'Your account has been banned',
});
}
});
events.on(USERS_USERNAME_STATUS_CHANGE, async (user, status) => {
if (status === 'REJECTED') {
const message = i18n.t('reject_username.email_message_reject');
await UsersService.sendEmail(user, {
template: 'suspension',
locals: {
body: message
},
subject: 'Username Rejected'
});
}
});
// 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;
};