mirror of
https://github.com/wassname/talk.git
synced 2026-06-28 17:51:53 +08:00
1211 lines
30 KiB
JavaScript
1211 lines
30 KiB
JavaScript
const uuid = require('uuid');
|
|
const moment = require('moment');
|
|
const bcrypt = require('bcryptjs');
|
|
const {
|
|
ErrMaxRateLimit,
|
|
ErrLoginAttemptMaximumExceeded,
|
|
ErrNotFound,
|
|
ErrPermissionUpdateUsername,
|
|
ErrSameUsernameProvided,
|
|
ErrUsernameTaken,
|
|
ErrMissingUsername,
|
|
ErrSpecialChars,
|
|
ErrMissingPassword,
|
|
ErrPasswordTooShort,
|
|
ErrMissingEmail,
|
|
ErrEmailTaken,
|
|
ErrEmailAlreadyVerified,
|
|
ErrCannotIgnoreStaff,
|
|
} = require('../errors');
|
|
const { difference, sample, some, merge, random } = require('lodash');
|
|
const {
|
|
ROOT_URL,
|
|
RECAPTCHA_WINDOW,
|
|
RECAPTCHA_INCORRECT_TRIGGER,
|
|
USERNAME_CAST_REGEXP,
|
|
USERNAME_REPLACEMENT_CAST_REGEXP,
|
|
USERNAME_REPLACEMENT_CHARACTER,
|
|
USERNAME_VALIDATION_REGEX,
|
|
} = require('../config');
|
|
const { jwt: JWT_SECRET } = require('../secrets');
|
|
const debug = require('debug')('talk:services:users');
|
|
const User = require('../models/user');
|
|
const Actions = require('./actions');
|
|
const mailer = require('./mailer');
|
|
const i18n = require('./i18n');
|
|
const Wordlist = require('./wordlist');
|
|
const DomainList = require('./domain_list');
|
|
const Limit = require('./limit');
|
|
const { get } = require('lodash');
|
|
|
|
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 loginRateLimiter = new Limit(
|
|
'loginAttempts',
|
|
RECAPTCHA_INCORRECT_TRIGGER,
|
|
RECAPTCHA_WINDOW
|
|
);
|
|
|
|
// upsertUser will try to lookup the user by their profile. If the user cannot
|
|
// be looked up, it will create one with a unique username and the designated
|
|
// username status.
|
|
async function upsertUser(
|
|
ctx,
|
|
id,
|
|
provider,
|
|
displayName,
|
|
usernameStatus,
|
|
shouldSetDisplayName = false
|
|
) {
|
|
let user = await User.findOne({
|
|
$or: [
|
|
{ id },
|
|
{
|
|
profiles: {
|
|
$elemMatch: {
|
|
id,
|
|
provider,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
if (user) {
|
|
user.wasUpserted = false;
|
|
user.$ignore('wasUpserted');
|
|
return user;
|
|
}
|
|
|
|
// User does not exist and need to be created.
|
|
|
|
// Create an initial username for the user.
|
|
let username = await Users.getInitialUsername(displayName);
|
|
|
|
// The user was not found, lets create them!
|
|
user = new User({
|
|
id,
|
|
username,
|
|
lowercaseUsername: username.toLowerCase(),
|
|
profiles: [{ id, provider }],
|
|
status: {
|
|
username: {
|
|
status: usernameStatus,
|
|
history: [{ status: usernameStatus }],
|
|
},
|
|
},
|
|
});
|
|
|
|
if (shouldSetDisplayName) {
|
|
// Set the displayName on the user metadata so that it can be accessed.
|
|
user.metadata = user.metadata || {};
|
|
user.metadata.displayName = displayName;
|
|
}
|
|
|
|
// Save the user in the database.
|
|
await user.save();
|
|
|
|
if (ctx) {
|
|
// Emit that the user was created if the context is set.
|
|
ctx.pubsub.publish('userCreated', user);
|
|
}
|
|
|
|
// Indicate that the user was upserted.
|
|
user.wasUpserted = true;
|
|
user.$ignore('wasUpserted');
|
|
|
|
return user;
|
|
}
|
|
|
|
// Users is the interface for the application to interact with the
|
|
// User through.
|
|
class Users {
|
|
/**
|
|
* Returns a user (if found) for the given email address.
|
|
*/
|
|
static findLocalUser(email) {
|
|
return User.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 instanceof ErrMaxRateLimit) {
|
|
throw new ErrLoginAttemptMaximumExceeded();
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
static async setSuspensionStatus(id, until, assignedBy = null, message) {
|
|
let user = await User.findOneAndUpdate(
|
|
{ id },
|
|
{
|
|
$set: {
|
|
'status.suspension.until': until,
|
|
},
|
|
$push: {
|
|
'status.suspension.history': {
|
|
until,
|
|
assigned_by: assignedBy,
|
|
message,
|
|
created_at: Date.now(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
new: true,
|
|
runValidators: true,
|
|
}
|
|
);
|
|
if (user === null) {
|
|
user = await User.findOne({ id });
|
|
if (user === null) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
// Date comparisons are difficult when using MongoDB. Javascript will
|
|
// store the date at a higher precision than Mongo can store, hence we
|
|
// check if the date is within 1 second of the time we're trying to set.
|
|
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'
|
|
);
|
|
}
|
|
|
|
// Check to see if the user was suspended now and is currently suspended.
|
|
if (user.suspended && message && message.length > 0) {
|
|
await Users.sendEmail(user, {
|
|
template: 'plain',
|
|
locals: {
|
|
body: message,
|
|
},
|
|
subject: 'Your account has been suspended', // TODO: replace with translation
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
static async setAlwaysPremodStatus(id, status, assignedBy = null) {
|
|
let user = await User.findOneAndUpdate(
|
|
{
|
|
id,
|
|
'status.alwaysPremod.status': {
|
|
$ne: status,
|
|
},
|
|
},
|
|
{
|
|
$set: {
|
|
'status.alwaysPremod.status': status,
|
|
},
|
|
$push: {
|
|
'status.alwaysPremod.history': {
|
|
status,
|
|
assigned_by: assignedBy,
|
|
created_at: Date.now(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
new: true,
|
|
runValidators: true,
|
|
}
|
|
);
|
|
|
|
if (!user) {
|
|
user = await User.findOne({ id });
|
|
if (!user) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
if (user.status.alwaysPremod.status === status) {
|
|
return user;
|
|
}
|
|
|
|
throw new Error(
|
|
'always premod status change edit failed for an unknown reason'
|
|
);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
static async setBanStatus(id, status, assignedBy = null, message) {
|
|
let user = await User.findOneAndUpdate(
|
|
{
|
|
id,
|
|
'status.banned.status': {
|
|
$ne: status,
|
|
},
|
|
},
|
|
{
|
|
$set: {
|
|
'status.banned.status': status,
|
|
},
|
|
$push: {
|
|
'status.banned.history': {
|
|
status,
|
|
assigned_by: assignedBy,
|
|
message,
|
|
created_at: Date.now(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
new: true,
|
|
runValidators: true,
|
|
}
|
|
);
|
|
if (!user) {
|
|
user = await User.findOne({ id });
|
|
if (!user) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
if (user.status.banned.status === status) {
|
|
return user;
|
|
}
|
|
|
|
throw new Error('ban status change edit failed for an unknown reason');
|
|
}
|
|
|
|
// Check to see if the user was banned now and is currently banned.
|
|
if (user.banned && status && message && message.length > 0) {
|
|
await Users.sendEmail(user, {
|
|
template: 'plain',
|
|
locals: {
|
|
body: message,
|
|
},
|
|
subject: 'Your account has been banned',
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
static async setUsernameStatus(id, status, assignedBy = null) {
|
|
let user = await User.findOneAndUpdate(
|
|
{
|
|
id,
|
|
'status.username.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 User.findOne({ id });
|
|
if (user === null) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
if (user.status.username.status === status) {
|
|
return user;
|
|
}
|
|
|
|
throw new Error(
|
|
'username status change edit failed for an unknown reason'
|
|
);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
static async setUsername(id, username, assignedBy) {
|
|
try {
|
|
const oldestEditTime = moment()
|
|
.subtract(14, 'days')
|
|
.toDate();
|
|
|
|
// A username can be set if:
|
|
//
|
|
// - The previous status was 'UNSET'
|
|
// - The username has not been changed within the last 14 days.
|
|
const query = {
|
|
id,
|
|
$or: [
|
|
{
|
|
'status.username.status': 'UNSET',
|
|
},
|
|
{
|
|
'status.username.status': { $in: ['APPROVED', 'SET'] },
|
|
$or: [
|
|
{
|
|
'status.username.history.created_at': {
|
|
$lte: oldestEditTime,
|
|
},
|
|
},
|
|
{
|
|
'status.username.history': [],
|
|
},
|
|
{
|
|
'status.username.history': { $exists: false },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const update = {
|
|
$set: {
|
|
username,
|
|
lowercaseUsername: username.toLowerCase(),
|
|
'status.username.status': 'SET',
|
|
},
|
|
$push: {
|
|
'status.username.history': {
|
|
status: 'SET',
|
|
assigned_by: assignedBy,
|
|
created_at: Date.now(),
|
|
},
|
|
},
|
|
};
|
|
|
|
let user = await User.findOneAndUpdate(query, update, {
|
|
new: true,
|
|
});
|
|
if (!user) {
|
|
user = await Users.findById(id);
|
|
if (user === null) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
if (
|
|
!['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) ||
|
|
user.status.username.history.some(({ created_at }) =>
|
|
moment(created_at).isAfter(oldestEditTime)
|
|
)
|
|
) {
|
|
throw new ErrPermissionUpdateUsername();
|
|
}
|
|
|
|
throw new Error('edit username failed for an unexpected reason');
|
|
}
|
|
|
|
return user;
|
|
} catch (err) {
|
|
if (err.code === 11000) {
|
|
throw new ErrUsernameTaken();
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
static async changeUsername(id, username, assignedBy) {
|
|
try {
|
|
const query = {
|
|
id,
|
|
username: { $ne: username },
|
|
'status.username.status': 'REJECTED',
|
|
};
|
|
|
|
const update = {
|
|
$set: {
|
|
username,
|
|
lowercaseUsername: username.toLowerCase(),
|
|
'status.username.status': 'CHANGED',
|
|
},
|
|
$push: {
|
|
'status.username.history': {
|
|
status: 'CHANGED',
|
|
assigned_by: assignedBy,
|
|
created_at: Date.now(),
|
|
},
|
|
},
|
|
};
|
|
|
|
let user = await User.findOneAndUpdate(query, update, {
|
|
new: true,
|
|
});
|
|
if (!user) {
|
|
user = await Users.findById(id);
|
|
if (user === null) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
if (user.status.username.status !== 'REJECTED') {
|
|
throw new ErrPermissionUpdateUsername();
|
|
}
|
|
|
|
if (user.username === username) {
|
|
throw new ErrSameUsernameProvided();
|
|
}
|
|
|
|
throw new Error('edit username failed for an unexpected reason');
|
|
}
|
|
|
|
return user;
|
|
} catch (err) {
|
|
if (err.code === 11000) {
|
|
throw new ErrUsernameTaken();
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 new ErrLoginAttemptMaximumExceeded();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets or removes the recaptcha_required flag on a user's local profile.
|
|
*/
|
|
static flagForRecaptchaRequirement(email, required) {
|
|
return User.update(
|
|
{
|
|
profiles: {
|
|
$elemMatch: {
|
|
id: email.toLowerCase(),
|
|
provider: 'local',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
$set: {
|
|
'profiles.$.metadata.recaptcha_required': required,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
static castUsername(username) {
|
|
return username
|
|
.trim()
|
|
.replace(USERNAME_REPLACEMENT_CAST_REGEXP, USERNAME_REPLACEMENT_CHARACTER)
|
|
.replace(USERNAME_CAST_REGEXP, '');
|
|
}
|
|
|
|
/**
|
|
* Creates the initial username for an external account. Searches to make
|
|
* sure username not already used. Adds a random number if username already
|
|
* in use.
|
|
*/
|
|
static async getInitialUsername(username) {
|
|
const MAX_ATTEMPTS = 10;
|
|
const END_NUMBER_MAX = 99999;
|
|
const GROUP_ATTEMPTS = 50;
|
|
|
|
// Cast the original username.
|
|
const castedName = Users.castUsername(username);
|
|
const lowercaseUsername = castedName.toLowerCase();
|
|
|
|
// Try to see if our first guess has been taken.
|
|
const existingUserWithName = await User.findOne({
|
|
lowercaseUsername,
|
|
});
|
|
if (!existingUserWithName) {
|
|
return castedName;
|
|
}
|
|
|
|
// Our first username was taken, lets try to find a non-taken name.
|
|
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
// Generate `GROUP_ATTEMPTS` guesses for the username.
|
|
const usernameGuesses = Array.from(Array(GROUP_ATTEMPTS)).map(
|
|
() =>
|
|
`${castedName}${USERNAME_REPLACEMENT_CHARACTER}${random(
|
|
0,
|
|
END_NUMBER_MAX
|
|
)}`
|
|
);
|
|
|
|
// Map them all to lowercase.
|
|
const lowercaseUsernameGuesses = usernameGuesses.map(guess =>
|
|
guess.toLowerCase()
|
|
);
|
|
|
|
// See if any of these users aren't taken already.
|
|
const existingUsernames = (await User.find(
|
|
{
|
|
lowercaseUsername: { $in: lowercaseUsernameGuesses },
|
|
},
|
|
{ lowercaseUsername: 1 }
|
|
)).map(({ lowercaseUsername }) => lowercaseUsername);
|
|
if (existingUsernames.length === lowercaseUsernameGuesses.length) {
|
|
// The number of found users is the same as the number of username
|
|
// guesses, aka, all the usernames are taken.
|
|
continue;
|
|
}
|
|
|
|
// At least one of the usernames wasn't taken! Let's filter this to only
|
|
// include unused usernames and grab one random entry from the list.
|
|
const foundLowercaseUsernameIndex = lowercaseUsernameGuesses.indexOf(
|
|
sample(difference(lowercaseUsernameGuesses, existingUsernames))
|
|
);
|
|
|
|
// Now we get the uppercase version of that string.
|
|
return usernameGuesses[foundLowercaseUsernameIndex];
|
|
}
|
|
|
|
throw new Error(
|
|
'cannot find free name after ' +
|
|
(MAX_ATTEMPTS * GROUP_ATTEMPTS + 1) +
|
|
' tries'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* upsertExternalUser will create or lookup a user where the user will not be
|
|
* able to change their username.
|
|
*
|
|
* @param {Object} ctx the graph context
|
|
* @param {String} id the ID for the user from the provider
|
|
* @param {String} provider the name of the provider
|
|
* @param {String} displayName the users desired displayName, not guaranteed
|
|
*/
|
|
static async upsertExternalUser(ctx, id, provider, displayName) {
|
|
return upsertUser(ctx, id, provider, displayName, 'SET', true);
|
|
}
|
|
|
|
/**
|
|
* upsertSocialUser will create or lookup a user as provided from a social
|
|
* graph.
|
|
*
|
|
* @param {Object} ctx the graph context
|
|
* @param {String} id the ID for the user from the provider
|
|
* @param {String} provider the name of the provider
|
|
* @param {String} displayName the users desired displayName, not guaranteed
|
|
*/
|
|
static async upsertSocialUser(ctx, id, provider, displayName) {
|
|
return upsertUser(ctx, id, provider, displayName, 'UNSET');
|
|
}
|
|
|
|
/**
|
|
* Finds a user given a social profile and if the user does not exist, creates
|
|
* them.
|
|
*/
|
|
static async findOrCreateExternalUser(ctx, id, provider, displayName) {
|
|
ctx.log.warn(
|
|
'findOrCreateExternalUser is deprecated and will be removed in a future version, replace with upsertExternalUser'
|
|
);
|
|
|
|
return Users.upsertSocialUser(ctx, id, provider, displayName);
|
|
}
|
|
|
|
/**
|
|
* 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 Users.createEmailConfirmToken(user, email, redirectURI);
|
|
|
|
return mailer.send({
|
|
template: 'email-confirm',
|
|
locals: {
|
|
token,
|
|
rootURL: ROOT_URL,
|
|
email,
|
|
},
|
|
subject: i18n.t('email.confirm.subject'),
|
|
email,
|
|
});
|
|
}
|
|
|
|
static async sendEmail(user, options) {
|
|
return mailer.send(
|
|
merge({}, options, {
|
|
user: user.id,
|
|
})
|
|
);
|
|
}
|
|
|
|
static async changePassword(id, password) {
|
|
if (!password || password.length < 8) {
|
|
throw new ErrPasswordTooShort();
|
|
}
|
|
|
|
const hashedPassword = await Users.hashPassword(password);
|
|
|
|
return User.update(
|
|
{ id },
|
|
{
|
|
$inc: { __v: 1 },
|
|
$set: {
|
|
password: hashedPassword,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (!username) {
|
|
throw new ErrMissingUsername();
|
|
}
|
|
|
|
if (!USERNAME_VALIDATION_REGEX.test(username)) {
|
|
throw new 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) {
|
|
throw new ErrMissingPassword();
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
throw new ErrPasswordTooShort();
|
|
}
|
|
|
|
return password;
|
|
}
|
|
|
|
/**
|
|
* Creates the local user with a given email, password, and name.
|
|
* @param {Object} ctx application context for the request
|
|
* @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
|
|
*/
|
|
static async createLocalUser(ctx, email, password, username) {
|
|
if (!email) {
|
|
throw new ErrMissingEmail();
|
|
}
|
|
|
|
email = email.toLowerCase().trim();
|
|
username = username.trim();
|
|
|
|
await Promise.all([
|
|
Users.isValidUsername(username),
|
|
Users.isValidPassword(password),
|
|
]);
|
|
|
|
const hashedPassword = await Users.hashPassword(password);
|
|
|
|
let user = new User({
|
|
username,
|
|
lowercaseUsername: username.toLowerCase(),
|
|
password: hashedPassword,
|
|
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 new ErrUsernameTaken();
|
|
}
|
|
throw new ErrEmailTaken();
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Emit that the user was created.
|
|
ctx.pubsub.publish('userCreated', user);
|
|
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* Sets a given user's role to the one provided.
|
|
* @param {String} id id of a user
|
|
* @param {String} role role to add
|
|
*/
|
|
static setRole(id, role) {
|
|
return User.update({ id }, { $set: { role } }, { runValidators: true });
|
|
}
|
|
|
|
/**
|
|
* Finds a user with the id.
|
|
* @param {String} id user id (uuid)
|
|
*/
|
|
static findById(id) {
|
|
return User.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) {
|
|
// FIXME: (wyattjoh) adding the `{ 'profiles.id': id }` search condition is
|
|
// a workaround for the `upsertExternalUser` bug where the ID being set was
|
|
// incorrect. Patch the user creation functions to adapt to the correct
|
|
// behavior. Thankfully, this is currently still covered by an index.
|
|
|
|
// Try to get the user.
|
|
let user = await User.findOne({ $or: [{ id }, { 'profiles.id': 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 User.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 User.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 ErrMissingEmail();
|
|
}
|
|
|
|
email = email.toLowerCase();
|
|
|
|
const [user, domainValidated] = await Promise.all([
|
|
User.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);
|
|
});
|
|
});
|
|
}
|
|
|
|
// TODO: update doc
|
|
static async verifyPasswordResetToken(token) {
|
|
if (!token) {
|
|
throw new Error('cannot verify an empty token');
|
|
}
|
|
|
|
const { userId, loc: redirect, version } = await Users.verifyToken(token, {
|
|
subject: PASSWORD_RESET_JWT_SUBJECT,
|
|
});
|
|
|
|
const user = await Users.findById(userId);
|
|
|
|
if (version !== user.__v) {
|
|
throw new Error('password reset token has expired');
|
|
}
|
|
|
|
return { user, redirect, version };
|
|
}
|
|
|
|
static async hashPassword(password) {
|
|
return bcrypt.hash(password, SALT_ROUNDS);
|
|
}
|
|
|
|
// TODO: update doc
|
|
static async resetPassword(token, password) {
|
|
const { user, redirect, version } = await this.verifyPasswordResetToken(
|
|
token
|
|
);
|
|
|
|
if (!password || password.length < 8) {
|
|
throw new ErrPasswordTooShort();
|
|
}
|
|
|
|
const hashedPassword = await Users.hashPassword(password);
|
|
|
|
// Update the user's password.
|
|
await User.update(
|
|
{ id: user.id, __v: version },
|
|
{
|
|
$inc: { __v: 1 },
|
|
$set: {
|
|
password: hashedPassword,
|
|
},
|
|
}
|
|
);
|
|
|
|
return { user, redirect };
|
|
}
|
|
|
|
/**
|
|
* Returns a count of the current users.
|
|
* @return {Promise}
|
|
*/
|
|
static count(query = {}) {
|
|
return User.count(query);
|
|
}
|
|
|
|
/**
|
|
* Returns all the users.
|
|
* @return {Promise}
|
|
*/
|
|
static all() {
|
|
return User.find();
|
|
}
|
|
|
|
/**
|
|
* Updates the user's settings.
|
|
* @return {Promise}
|
|
*/
|
|
static updateSettings(id, settings) {
|
|
return User.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 Actions.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 ErrEmailAlreadyVerified();
|
|
}
|
|
|
|
return JWT_SECRET.sign(
|
|
{
|
|
email,
|
|
referer,
|
|
userID: user.id,
|
|
},
|
|
tokenOptions
|
|
);
|
|
}
|
|
|
|
/**
|
|
* verifyEmailConfirmationToken checks the validity of a given token without
|
|
* actually confirming the user's email address.
|
|
*
|
|
* @param {String} token the token to verify
|
|
*/
|
|
static async verifyEmailConfirmationToken(token) {
|
|
if (!token) {
|
|
throw new Error('cannot verify an empty token');
|
|
}
|
|
|
|
const decoded = await Users.verifyToken(token, {
|
|
subject: EMAIL_CONFIRM_JWT_SUBJECT,
|
|
});
|
|
|
|
const user = await User.findOne({
|
|
id: decoded.userID,
|
|
profiles: {
|
|
$elemMatch: {
|
|
id: decoded.email,
|
|
provider: 'local',
|
|
},
|
|
},
|
|
});
|
|
if (!user) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
const profile = user.profiles.find(({ id }) => id === decoded.email);
|
|
if (!profile) {
|
|
throw new ErrNotFound();
|
|
}
|
|
|
|
// Check to see if the profile has already been confirmed.
|
|
const confirmedAt = get(profile, 'metadata.confirmed_at', null);
|
|
if (confirmedAt && confirmedAt < Date.now()) {
|
|
throw new ErrEmailAlreadyVerified();
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
/**
|
|
* 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 Users.verifyEmailConfirmationToken(
|
|
token
|
|
);
|
|
|
|
await Users.confirmEmail(userID, email);
|
|
|
|
return { userID, email, referer };
|
|
}
|
|
|
|
/**
|
|
* Marks the email on the user as confirmed.
|
|
*/
|
|
static confirmEmail(id, email) {
|
|
return User.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 Users.findByIdArray(usersToIgnore);
|
|
if (some(users, user => user.isStaff())) {
|
|
throw new ErrCannotIgnoreStaff();
|
|
}
|
|
|
|
return User.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 User.update(
|
|
{ id },
|
|
{
|
|
$pullAll: {
|
|
ignoresUsers: usersToStopIgnoring,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = Users;
|
|
|
|
// 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;
|
|
};
|