UserUpdater.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. const logger = require('@overleaf/logger')
  2. const OError = require('@overleaf/o-error')
  3. const { db } = require('../../infrastructure/mongodb')
  4. const { normalizeQuery } = require('../Helpers/Mongo')
  5. const { callbackify } = require('util')
  6. const UserGetter = require('./UserGetter')
  7. const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
  8. const Features = require('../../infrastructure/Features')
  9. const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
  10. const EmailHandler = require('../Email/EmailHandler')
  11. const EmailHelper = require('../Helpers/EmailHelper')
  12. const Errors = require('../Errors/Errors')
  13. const NewsletterManager = require('../Newsletter/NewsletterManager')
  14. const RecurlyWrapper = require('../Subscription/RecurlyWrapper')
  15. const UserAuditLogHandler = require('./UserAuditLogHandler')
  16. const AnalyticsManager = require('../Analytics/AnalyticsManager')
  17. const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
  18. const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
  19. const _ = require('lodash')
  20. const Modules = require('../../infrastructure/Modules')
  21. async function _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email) {
  22. // Send email to the following:
  23. // - the old primary
  24. // - the new primary
  25. // - for all other current (confirmed or recently-enough reconfirmed) email addresses, group by institution if we
  26. // have it, or domain if we don’t, and for each group send to the most recently reconfirmed (or confirmed if never
  27. // reconfirmed) address in that group.
  28. // See #6101.
  29. const emailOptions = {
  30. actionDescribed: `the primary email address on your account was changed to ${email}`,
  31. action: 'change of primary email address',
  32. }
  33. async function sendToRecipients(recipients) {
  34. // On failure, log the error and carry on so that one email failing does not prevent other emails sending
  35. for await (const recipient of recipients) {
  36. try {
  37. const opts = Object.assign({}, emailOptions, { to: recipient })
  38. await EmailHandler.promises.sendEmail('securityAlert', opts)
  39. } catch (error) {
  40. logger.error(
  41. { error, userId },
  42. 'could not send security alert email when primary email changed'
  43. )
  44. }
  45. }
  46. }
  47. // First, send notification to the old and new primary emails before getting other emails from v1 to ensure that these
  48. // are still sent in the event of not being able to reach v1
  49. const oldAndNewPrimaryEmails = [oldEmail, email]
  50. await sendToRecipients(oldAndNewPrimaryEmails)
  51. // Next, get extra recipients with affiliation data
  52. const emailsData = await UserGetter.promises.getUserFullEmails(userId)
  53. const extraRecipients = _securityAlertPrimaryEmailChangedExtraRecipients(
  54. emailsData,
  55. oldEmail,
  56. email
  57. )
  58. await sendToRecipients(extraRecipients)
  59. }
  60. /**
  61. * Add a new email address for the user. Email cannot be already used by this
  62. * or any other user
  63. */
  64. async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
  65. newEmail = EmailHelper.parseEmail(newEmail)
  66. if (!newEmail) {
  67. throw new Error('invalid email')
  68. }
  69. await UserGetter.promises.ensureUniqueEmailAddress(newEmail)
  70. AnalyticsManager.recordEventForUserInBackground(
  71. userId,
  72. 'secondary-email-added'
  73. )
  74. await UserAuditLogHandler.promises.addEntry(
  75. userId,
  76. 'add-email',
  77. auditLog.initiatorId,
  78. auditLog.ipAddress,
  79. {
  80. newSecondaryEmail: newEmail,
  81. }
  82. )
  83. try {
  84. await InstitutionsAPI.promises.addAffiliation(
  85. userId,
  86. newEmail,
  87. affiliationOptions
  88. )
  89. } catch (error) {
  90. throw OError.tag(error, 'problem adding affiliation while adding email')
  91. }
  92. try {
  93. const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
  94. const update = {
  95. $push: {
  96. emails: { email: newEmail, createdAt: new Date(), reversedHostname },
  97. },
  98. }
  99. await updateUser({ _id: userId, 'emails.email': { $ne: newEmail } }, update)
  100. } catch (error) {
  101. throw OError.tag(error, 'problem updating users emails')
  102. }
  103. }
  104. async function clearSAMLData(userId, auditLog, sendEmail) {
  105. const user = await UserGetter.promises.getUser(userId, {
  106. email: 1,
  107. emails: 1,
  108. })
  109. await UserAuditLogHandler.promises.addEntry(
  110. userId,
  111. 'clear-institution-sso-data',
  112. auditLog.initiatorId,
  113. auditLog.ipAddress,
  114. {}
  115. )
  116. const update = {
  117. $unset: {
  118. samlIdentifiers: 1,
  119. 'emails.$[].samlProviderId': 1,
  120. 'enrollment.sso': 1,
  121. },
  122. }
  123. await updateUser(userId, update)
  124. for (const emailData of user.emails) {
  125. await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
  126. }
  127. await FeaturesUpdater.promises.refreshFeatures(
  128. userId,
  129. 'clear-institution-sso-data'
  130. )
  131. if (sendEmail) {
  132. await EmailHandler.promises.sendEmail('SAMLDataCleared', { to: user.email })
  133. }
  134. }
  135. /**
  136. * set the default email address by setting the `email` attribute. The email
  137. * must be one of the user's multiple emails (`emails` attribute)
  138. */
  139. async function setDefaultEmailAddress(
  140. userId,
  141. email,
  142. allowUnconfirmed,
  143. auditLog,
  144. sendSecurityAlert
  145. ) {
  146. email = EmailHelper.parseEmail(email)
  147. if (email == null) {
  148. throw new Error('invalid email')
  149. }
  150. const user = await UserGetter.promises.getUser(userId, {
  151. email: 1,
  152. emails: 1,
  153. })
  154. if (!user) {
  155. throw new Error('invalid userId')
  156. }
  157. const oldEmail = user.email
  158. const userEmail = user.emails.find(e => e.email === email)
  159. if (!userEmail) {
  160. throw new Error('Default email does not belong to user')
  161. }
  162. if (!userEmail.confirmedAt && !allowUnconfirmed) {
  163. throw new Errors.UnconfirmedEmailError()
  164. }
  165. await UserAuditLogHandler.promises.addEntry(
  166. userId,
  167. 'change-primary-email',
  168. auditLog.initiatorId,
  169. auditLog.ipAddress,
  170. {
  171. newPrimaryEmail: email,
  172. oldPrimaryEmail: oldEmail,
  173. }
  174. )
  175. const query = { _id: userId, 'emails.email': email }
  176. const update = { $set: { email, lastPrimaryEmailCheck: new Date() } }
  177. const res = await updateUser(query, update)
  178. // this should not happen
  179. if (res.matchedCount !== 1) {
  180. throw new Error('email update error')
  181. }
  182. AnalyticsManager.recordEventForUserInBackground(
  183. userId,
  184. 'primary-email-address-updated'
  185. )
  186. if (sendSecurityAlert) {
  187. // no need to wait, errors are logged and not passed back
  188. _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email).catch(
  189. err => {
  190. logger.error({ err }, 'failed to send security alert email')
  191. }
  192. )
  193. }
  194. try {
  195. await NewsletterManager.promises.changeEmail(user, email)
  196. } catch (error) {
  197. logger.warn(
  198. { err: error, oldEmail, newEmail: email },
  199. 'Failed to change email in newsletter subscription'
  200. )
  201. }
  202. try {
  203. await Modules.promises.hooks.fire('userEmailChanged', user, email)
  204. } catch (err) {
  205. logger.error(
  206. { err, oldEmail, newEmail: email },
  207. 'Failed to fire "userEmailChanged" hook'
  208. )
  209. }
  210. try {
  211. await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email)
  212. } catch (error) {
  213. // errors are ignored
  214. }
  215. }
  216. /**
  217. * Overwrites the primary email address of a user in the database in-place.
  218. * This function is only intended for use in scripts to migrate email addresses
  219. * where we do not want to trigger all the actions that happen when a user
  220. * changes their own email. It should not be used in any other circumstances.
  221. */
  222. async function migrateDefaultEmailAddress(
  223. userId,
  224. oldEmail,
  225. newEmail,
  226. auditLog
  227. ) {
  228. oldEmail = EmailHelper.parseEmail(oldEmail)
  229. if (oldEmail == null) {
  230. throw new Error('invalid old email')
  231. }
  232. newEmail = EmailHelper.parseEmail(newEmail)
  233. if (newEmail == null) {
  234. throw new Error('invalid new email')
  235. }
  236. const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
  237. const query = {
  238. _id: userId,
  239. email: oldEmail,
  240. 'emails.email': oldEmail,
  241. }
  242. const update = {
  243. $set: {
  244. email: newEmail,
  245. 'emails.$.email': newEmail,
  246. 'emails.$.reversedHostname': reversedHostname,
  247. },
  248. }
  249. const result = await updateUser(query, update)
  250. if (result.modifiedCount !== 1) {
  251. throw new Error('email update error')
  252. }
  253. // add a user audit log entry for the email change
  254. await UserAuditLogHandler.promises.addEntry(
  255. userId,
  256. 'migrate-default-email',
  257. auditLog.initiatorId,
  258. auditLog.ipAddress,
  259. {
  260. oldEmail,
  261. newEmail,
  262. // Add optional extra info
  263. ...(auditLog.extraInfo || {}),
  264. }
  265. )
  266. }
  267. async function confirmEmail(userId, email, affiliationOptions) {
  268. // used for initial email confirmation (non-SSO and SSO)
  269. // also used for reconfirmation of non-SSO emails
  270. const confirmedAt = new Date()
  271. email = EmailHelper.parseEmail(email)
  272. if (email == null) {
  273. throw new Error('invalid email')
  274. }
  275. logger.debug({ userId, email }, 'confirming user email')
  276. try {
  277. affiliationOptions = affiliationOptions || {}
  278. affiliationOptions.confirmedAt = confirmedAt
  279. await InstitutionsAPI.promises.addAffiliation(
  280. userId,
  281. email,
  282. affiliationOptions
  283. )
  284. } catch (error) {
  285. throw OError.tag(error, 'problem adding affiliation while confirming email')
  286. }
  287. const query = {
  288. _id: userId,
  289. 'emails.email': email,
  290. }
  291. // only update confirmedAt if it was not previously set
  292. const update = {
  293. $set: {
  294. 'emails.$.reconfirmedAt': confirmedAt,
  295. },
  296. $min: {
  297. 'emails.$.confirmedAt': confirmedAt,
  298. },
  299. }
  300. if (Features.hasFeature('affiliations')) {
  301. update.$unset = {
  302. 'emails.$.affiliationUnchecked': 1,
  303. }
  304. }
  305. const res = await updateUser(query, update)
  306. if (res.matchedCount !== 1) {
  307. throw new Errors.NotFoundError('user id and email do no match')
  308. }
  309. await FeaturesUpdater.promises.refreshFeatures(userId, 'confirm-email')
  310. try {
  311. await maybeCreateRedundantSubscriptionNotification(userId, email)
  312. } catch (error) {
  313. logger.err(
  314. { err: error },
  315. 'error checking redundant subscription on email confirmation'
  316. )
  317. }
  318. }
  319. async function maybeCreateRedundantSubscriptionNotification(userId, email) {
  320. const subscription =
  321. await SubscriptionLocator.promises.getUserIndividualSubscription(userId)
  322. if (!subscription || subscription.groupPlan) {
  323. return
  324. }
  325. const affiliations =
  326. await InstitutionsAPI.promises.getUserAffiliations(userId)
  327. const confirmedAffiliation = affiliations.find(a => a.email === email)
  328. if (!confirmedAffiliation || confirmedAffiliation.licence === 'free') {
  329. return
  330. }
  331. await NotificationsBuilder.promises
  332. .redundantPersonalSubscription(
  333. {
  334. institutionId: confirmedAffiliation.institution.id,
  335. institutionName: confirmedAffiliation.institution.name,
  336. },
  337. { _id: userId }
  338. )
  339. .create()
  340. }
  341. async function removeEmailAddress(
  342. userId,
  343. email,
  344. auditLog,
  345. skipParseEmail = false
  346. ) {
  347. // remove one of the user's email addresses. The email cannot be the user's
  348. // default email address
  349. if (!skipParseEmail) {
  350. email = EmailHelper.parseEmail(email)
  351. } else if (skipParseEmail && typeof email !== 'string') {
  352. throw new Error('email must be a string')
  353. }
  354. if (!email) {
  355. throw new Error('invalid email')
  356. }
  357. const isMainEmail = await UserGetter.promises.getUserByMainEmail(email, {
  358. _id: 1,
  359. })
  360. if (isMainEmail) {
  361. throw new Error('cannot remove primary email')
  362. }
  363. await UserAuditLogHandler.promises.addEntry(
  364. userId,
  365. 'remove-email',
  366. auditLog.initiatorId,
  367. auditLog.ipAddress,
  368. {
  369. removedEmail: email,
  370. // Add optional extra info
  371. ...(auditLog.extraInfo || {}),
  372. }
  373. )
  374. try {
  375. await InstitutionsAPI.promises.removeAffiliation(userId, email)
  376. } catch (error) {
  377. OError.tag(error, 'problem removing affiliation')
  378. throw error
  379. }
  380. const query = { _id: userId, email: { $ne: email } }
  381. const update = { $pull: { emails: { email } } }
  382. let res
  383. try {
  384. res = await updateUser(query, update)
  385. } catch (error) {
  386. OError.tag(error, 'problem removing users email')
  387. throw error
  388. }
  389. if (res.matchedCount !== 1) {
  390. throw new Error('Cannot remove email')
  391. }
  392. await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email')
  393. }
  394. async function addAffiliationForNewUser(
  395. userId,
  396. email,
  397. affiliationOptions = {}
  398. ) {
  399. await InstitutionsAPI.promises.addAffiliation(
  400. userId,
  401. email,
  402. affiliationOptions
  403. )
  404. try {
  405. await updateUser(
  406. { _id: userId, 'emails.email': email },
  407. { $unset: { 'emails.$.affiliationUnchecked': 1 } }
  408. )
  409. } catch (error) {
  410. logger.error(
  411. OError.tag(
  412. error,
  413. 'could not remove affiliationUnchecked flag for user on create',
  414. {
  415. userId,
  416. email,
  417. }
  418. )
  419. )
  420. }
  421. }
  422. async function updateUser(query, update) {
  423. query = normalizeQuery(query)
  424. const result = await db.users.updateOne(query, update)
  425. return result
  426. }
  427. /**
  428. * DEPRECATED
  429. *
  430. * Change the user's main email address by adding a new email, switching the
  431. * default email and removing the old email. Prefer manipulating multiple
  432. * emails and the default rather than calling this method directly
  433. */
  434. async function changeEmailAddress(userId, newEmail, auditLog) {
  435. newEmail = EmailHelper.parseEmail(newEmail)
  436. if (newEmail == null) {
  437. throw new Error('invalid email')
  438. }
  439. const oldEmail = await UserGetter.promises.getUserEmail(userId)
  440. await addEmailAddress(userId, newEmail, {}, auditLog)
  441. await setDefaultEmailAddress(userId, newEmail, true, auditLog, true)
  442. await removeEmailAddress(userId, oldEmail, auditLog)
  443. }
  444. async function removeReconfirmFlag(userId) {
  445. await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
  446. }
  447. function _securityAlertPrimaryEmailChangedExtraRecipients(
  448. emailsData,
  449. oldEmail,
  450. email
  451. ) {
  452. // Group by institution if we have it, or domain if we don’t, and for each group send to the most recently
  453. // reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new
  454. // primary email addresses because they are emailed separately
  455. // See #6101.
  456. function sortEmailsByConfirmation(emails) {
  457. return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt)
  458. }
  459. const recipients = new Set()
  460. const emailsToIgnore = new Set([oldEmail, email])
  461. // Remove non-confirmed emails
  462. const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt)
  463. // Group other emails by institution, separating out those with no institution and grouping them instead by domain.
  464. // The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format
  465. // to avoid any potential clash
  466. const groupedEmails = _.groupBy(confirmedEmails, emailData => {
  467. if (!emailData.affiliation || !emailData.affiliation.institution) {
  468. return `domain:${EmailHelper.getDomain(emailData.email)}`
  469. }
  470. return `institution_id:${emailData.affiliation.institution.id}`
  471. })
  472. // For each group of emails, order the emails by (re-)confirmation date and pick the first
  473. for (const emails of Object.values(groupedEmails)) {
  474. // Sort by confirmation and pick the first
  475. sortEmailsByConfirmation(emails)
  476. // Ignore original and new primary email addresses
  477. const recipient = emails[0].email
  478. if (!emailsToIgnore.has(recipient)) {
  479. recipients.add(emails[0].email)
  480. }
  481. }
  482. return Array.from(recipients)
  483. }
  484. module.exports = {
  485. addAffiliationForNewUser: callbackify(addAffiliationForNewUser),
  486. addEmailAddress: callbackify(addEmailAddress),
  487. changeEmailAddress: callbackify(changeEmailAddress),
  488. clearSAMLData: callbackify(clearSAMLData),
  489. confirmEmail: callbackify(confirmEmail),
  490. removeEmailAddress: callbackify(removeEmailAddress),
  491. removeReconfirmFlag: callbackify(removeReconfirmFlag),
  492. setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
  493. migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
  494. updateUser: callbackify(updateUser),
  495. promises: {
  496. addAffiliationForNewUser,
  497. addEmailAddress,
  498. changeEmailAddress,
  499. clearSAMLData,
  500. confirmEmail,
  501. removeEmailAddress,
  502. removeReconfirmFlag,
  503. setDefaultEmailAddress,
  504. migrateDefaultEmailAddress,
  505. updateUser,
  506. },
  507. }