overleaf/services/web/app/src/Features/User/UserUpdater.js

440 lines
13 KiB
JavaScript
Raw Normal View History

const logger = require('@overleaf/logger')
const OError = require('@overleaf/o-error')
const { db } = require('../../infrastructure/mongodb')
const { normalizeQuery } = require('../Helpers/Mongo')
const { callbackify } = require('util')
const UserGetter = require('./UserGetter')
const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
const Features = require('../../infrastructure/Features')
const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
const EmailHandler = require('../Email/EmailHandler')
const EmailHelper = require('../Helpers/EmailHelper')
const Errors = require('../Errors/Errors')
const NewsletterManager = require('../Newsletter/NewsletterManager')
const RecurlyWrapper = require('../Subscription/RecurlyWrapper')
const UserAuditLogHandler = require('./UserAuditLogHandler')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const _ = require('lodash')
async function _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email) {
// Send email to the following:
// - the old primary
// - the new primary
// - for all other current (confirmed or recently-enough reconfirmed) email addresses, group by institution if we
// have it, or domain if we dont, and for each group send to the most recently reconfirmed (or confirmed if never
// reconfirmed) address in that group.
// See #6101.
const emailOptions = {
actionDescribed: `the primary email address on your account was changed to ${email}`,
action: 'change of primary email address',
}
async function sendToRecipients(recipients) {
// On failure, log the error and carry on so that one email failing does not prevent other emails sending
for await (const recipient of recipients) {
try {
const opts = Object.assign({}, emailOptions, { to: recipient })
await EmailHandler.promises.sendEmail('securityAlert', opts)
} catch (error) {
logger.error(
{ error, userId },
'could not send security alert email when primary email changed'
)
}
}
}
// First, send notification to the old and new primary emails before getting other emails from v1 to ensure that these
// are still sent in the event of not being able to reach v1
const oldAndNewPrimaryEmails = [oldEmail, email]
await sendToRecipients(oldAndNewPrimaryEmails)
// Next, get extra recipients with affiliation data
const emailsData = await UserGetter.promises.getUserFullEmails(userId)
const extraRecipients = _securityAlertPrimaryEmailChangedExtraRecipients(
emailsData,
oldEmail,
email
)
await sendToRecipients(extraRecipients)
}
/**
* Add a new email address for the user. Email cannot be already used by this
* or any other user
*/
async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
newEmail = EmailHelper.parseEmail(newEmail)
if (!newEmail) {
throw new Error('invalid email')
}
await UserGetter.promises.ensureUniqueEmailAddress(newEmail)
AnalyticsManager.recordEventForUser(userId, 'secondary-email-added')
await UserAuditLogHandler.promises.addEntry(
userId,
'add-email',
auditLog.initiatorId,
auditLog.ipAddress,
{
newSecondaryEmail: newEmail,
}
)
try {
await InstitutionsAPI.promises.addAffiliation(
userId,
newEmail,
affiliationOptions
)
} catch (error) {
throw OError.tag(error, 'problem adding affiliation while adding email')
}
try {
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
const update = {
$push: {
emails: { email: newEmail, createdAt: new Date(), reversedHostname },
},
}
await updateUser(userId, update)
} catch (error) {
throw OError.tag(error, 'problem updating users emails')
}
}
async function clearSAMLData(userId, auditLog, sendEmail) {
const user = await UserGetter.promises.getUser(userId, {
email: 1,
emails: 1,
})
await UserAuditLogHandler.promises.addEntry(
userId,
'clear-institution-sso-data',
auditLog.initiatorId,
auditLog.ipAddress,
{}
)
const update = {
$unset: {
samlIdentifiers: 1,
'emails.$[].samlProviderId': 1,
},
}
await updateUser(userId, update)
for (const emailData of user.emails) {
await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
}
await FeaturesUpdater.promises.refreshFeatures(
userId,
'clear-institution-sso-data'
)
if (sendEmail) {
await EmailHandler.promises.sendEmail('SAMLDataCleared', { to: user.email })
}
}
/**
* set the default email address by setting the `email` attribute. The email
* must be one of the user's multiple emails (`emails` attribute)
*/
async function setDefaultEmailAddress(
userId,
email,
allowUnconfirmed,
auditLog,
sendSecurityAlert
) {
email = EmailHelper.parseEmail(email)
if (email == null) {
throw new Error('invalid email')
}
const user = await UserGetter.promises.getUser(userId, {
email: 1,
emails: 1,
})
if (!user) {
throw new Error('invalid userId')
}
const oldEmail = user.email
const userEmail = user.emails.find(e => e.email === email)
if (!userEmail) {
throw new Error('Default email does not belong to user')
}
if (!userEmail.confirmedAt && !allowUnconfirmed) {
throw new Errors.UnconfirmedEmailError()
}
await UserAuditLogHandler.promises.addEntry(
userId,
'change-primary-email',
auditLog.initiatorId,
auditLog.ipAddress,
{
newPrimaryEmail: email,
oldPrimaryEmail: oldEmail,
}
)
const query = { _id: userId, 'emails.email': email }
const update = { $set: { email, lastPrimaryEmailCheck: new Date() } }
const res = await updateUser(query, update)
// this should not happen
if (res.matchedCount !== 1) {
throw new Error('email update error')
}
AnalyticsManager.recordEventForUser(userId, 'primary-email-address-updated')
if (sendSecurityAlert) {
// no need to wait, errors are logged and not passed back
_sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email).catch(
err => {
logger.error({ err }, 'failed to send security alert email')
}
)
}
try {
await NewsletterManager.promises.changeEmail(user, email)
} catch (error) {
logger.warn(
{ err: error, oldEmail, newEmail: email },
'Failed to change email in newsletter subscription'
)
}
try {
await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email)
} catch (error) {
// errors are ignored
}
}
async function confirmEmail(userId, email) {
// used for initial email confirmation (non-SSO and SSO)
// also used for reconfirmation of non-SSO emails
const confirmedAt = new Date()
email = EmailHelper.parseEmail(email)
if (email == null) {
throw new Error('invalid email')
}
logger.debug({ userId, email }, 'confirming user email')
try {
await InstitutionsAPI.promises.addAffiliation(userId, email, {
confirmedAt,
})
} catch (error) {
throw OError.tag(error, 'problem adding affiliation while confirming email')
}
const query = {
_id: userId,
'emails.email': email,
}
// only update confirmedAt if it was not previously set
const update = {
$set: {
'emails.$.reconfirmedAt': confirmedAt,
},
$min: {
'emails.$.confirmedAt': confirmedAt,
},
}
if (Features.hasFeature('affiliations')) {
update.$unset = {
'emails.$.affiliationUnchecked': 1,
}
}
const res = await updateUser(query, update)
if (res.matchedCount !== 1) {
throw new Errors.NotFoundError('user id and email do no match')
}
await FeaturesUpdater.promises.refreshFeatures(userId, 'confirm-email')
}
async function removeEmailAddress(userId, email, skipParseEmail = false) {
// remove one of the user's email addresses. The email cannot be the user's
// default email address
if (!skipParseEmail) {
email = EmailHelper.parseEmail(email)
} else if (skipParseEmail && typeof email !== 'string') {
throw new Error('email must be a string')
}
if (!email) {
throw new Error('invalid email')
}
const isMainEmail = await UserGetter.promises.getUserByMainEmail(email, {
_id: 1,
})
if (isMainEmail) {
throw new Error('cannot remove primary email')
}
try {
await InstitutionsAPI.promises.removeAffiliation(userId, email)
} catch (error) {
OError.tag(error, 'problem removing affiliation')
throw error
}
const query = { _id: userId, email: { $ne: email } }
const update = { $pull: { emails: { email } } }
let res
try {
res = await updateUser(query, update)
} catch (error) {
OError.tag(error, 'problem removing users email')
throw error
}
if (res.matchedCount !== 1) {
throw new Error('Cannot remove email')
}
await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email')
}
async function addAffiliationForNewUser(
userId,
email,
affiliationOptions = {}
) {
await InstitutionsAPI.promises.addAffiliation(
userId,
email,
affiliationOptions
)
try {
await updateUser(
{ _id: userId, 'emails.email': email },
{ $unset: { 'emails.$.affiliationUnchecked': 1 } }
)
} catch (error) {
throw OError.tag(
error,
'could not remove affiliationUnchecked flag for user on create',
{
userId,
email,
}
)
}
}
async function updateUser(query, update) {
query = normalizeQuery(query)
const result = await db.users.updateOne(query, update)
return result
}
/**
* DEPRECATED
*
* Change the user's main email address by adding a new email, switching the
* default email and removing the old email. Prefer manipulating multiple
* emails and the default rather than calling this method directly
*/
async function changeEmailAddress(userId, newEmail, auditLog) {
newEmail = EmailHelper.parseEmail(newEmail)
if (newEmail == null) {
throw new Error('invalid email')
}
const oldEmail = await UserGetter.promises.getUserEmail(userId)
await addEmailAddress(userId, newEmail, {}, auditLog)
await setDefaultEmailAddress(userId, newEmail, true, auditLog, true)
await removeEmailAddress(userId, oldEmail)
}
async function removeReconfirmFlag(userId) {
await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
}
function _securityAlertPrimaryEmailChangedExtraRecipients(
emailsData,
oldEmail,
email
) {
// Group by institution if we have it, or domain if we dont, and for each group send to the most recently
// reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new
// primary email addresses because they are emailed separately
// See #6101.
function sortEmailsByConfirmation(emails) {
return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt)
}
const recipients = new Set()
const emailsToIgnore = new Set([oldEmail, email])
// Remove non-confirmed emails
const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt)
// Group other emails by institution, separating out those with no institution and grouping them instead by domain.
// The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format
// to avoid any potential clash
const groupedEmails = _.groupBy(confirmedEmails, emailData => {
if (!emailData.affiliation || !emailData.affiliation.institution) {
return `domain:${EmailHelper.getDomain(emailData.email)}`
}
return `institution_id:${emailData.affiliation.institution.id}`
})
// For each group of emails, order the emails by (re-)confirmation date and pick the first
for (const emails of Object.values(groupedEmails)) {
// Sort by confirmation and pick the first
sortEmailsByConfirmation(emails)
// Ignore original and new primary email addresses
const recipient = emails[0].email
if (!emailsToIgnore.has(recipient)) {
recipients.add(emails[0].email)
}
}
return Array.from(recipients)
}
module.exports = {
addAffiliationForNewUser: callbackify(addAffiliationForNewUser),
addEmailAddress: callbackify(addEmailAddress),
changeEmailAddress: callbackify(changeEmailAddress),
clearSAMLData: callbackify(clearSAMLData),
confirmEmail: callbackify(confirmEmail),
removeEmailAddress: callbackify(removeEmailAddress),
removeReconfirmFlag: callbackify(removeReconfirmFlag),
setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
updateUser: callbackify(updateUser),
promises: {
addAffiliationForNewUser,
addEmailAddress,
changeEmailAddress,
clearSAMLData,
confirmEmail,
removeEmailAddress,
removeReconfirmFlag,
setDefaultEmailAddress,
updateUser,
},
}