UserUpdater.js 17 KB

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