overleaf/services/web/app/src/Features/User/UserUpdater.js
Mathias Jakobsen e0c23d83da [web] Add auditing of email removals (#8904)
* [web] Add auditing of email removals

* [web] Improve auditing of email removal from script

GitOrigin-RevId: ccb948f01616a0bcb2d8f718d6b9e69585e8bb89
2022-07-27 12:17:31 +00:00

498 lines
14 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 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 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, 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 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,
},
}