mirror of
https://github.com/overleaf/overleaf.git
synced 2024-10-24 21:12:38 -04:00
e0c23d83da
* [web] Add auditing of email removals * [web] Improve auditing of email removal from script GitOrigin-RevId: ccb948f01616a0bcb2d8f718d6b9e69585e8bb89
498 lines
14 KiB
JavaScript
498 lines
14 KiB
JavaScript
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 SubscriptionLocator = require('../Subscription/SubscriptionLocator')
|
||
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
|
||
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 don’t, 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, affiliationOptions) {
|
||
// 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 {
|
||
affiliationOptions = affiliationOptions || {}
|
||
affiliationOptions.confirmedAt = confirmedAt
|
||
await InstitutionsAPI.promises.addAffiliation(
|
||
userId,
|
||
email,
|
||
affiliationOptions
|
||
)
|
||
} 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')
|
||
try {
|
||
await maybeCreateRedundantSubscriptionNotification(userId, email)
|
||
} catch (error) {
|
||
logger.err(
|
||
{ err: error },
|
||
'error checking redundant subscription on email confirmation'
|
||
)
|
||
}
|
||
}
|
||
|
||
async function maybeCreateRedundantSubscriptionNotification(userId, email) {
|
||
const subscription =
|
||
await SubscriptionLocator.promises.getUserIndividualSubscription(userId)
|
||
if (!subscription || subscription.groupPlan) {
|
||
return
|
||
}
|
||
|
||
const affiliations = await InstitutionsAPI.promises.getUserAffiliations(
|
||
userId
|
||
)
|
||
const confirmedAffiliation = affiliations.find(a => a.email === email)
|
||
if (!confirmedAffiliation || confirmedAffiliation.licence === 'free') {
|
||
return
|
||
}
|
||
|
||
await NotificationsBuilder.promises
|
||
.redundantPersonalSubscription(
|
||
{
|
||
institutionId: confirmedAffiliation.institution.id,
|
||
institutionName: confirmedAffiliation.institution.name,
|
||
},
|
||
{ _id: userId }
|
||
)
|
||
.create()
|
||
}
|
||
|
||
async function removeEmailAddress(
|
||
userId,
|
||
email,
|
||
auditLog,
|
||
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')
|
||
}
|
||
|
||
await UserAuditLogHandler.promises.addEntry(
|
||
userId,
|
||
'remove-email',
|
||
auditLog.initiatorId,
|
||
auditLog.ipAddress,
|
||
{
|
||
removedEmail: email,
|
||
// Add optional extra info
|
||
...(auditLog.extraInfo || {}),
|
||
}
|
||
)
|
||
|
||
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) {
|
||
logger.error(
|
||
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, auditLog)
|
||
}
|
||
|
||
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 don’t, 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,
|
||
},
|
||
}
|