UserEmailsController.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. import AuthenticationController from '../Authentication/AuthenticationController.mjs'
  2. import Settings from '@overleaf/settings'
  3. import logger from '@overleaf/logger'
  4. import SessionManager from '../Authentication/SessionManager.mjs'
  5. import UserGetter from './UserGetter.mjs'
  6. import UserUpdater from './UserUpdater.mjs'
  7. import UserSessionsManager from './UserSessionsManager.mjs'
  8. import EmailHandler from '../Email/EmailHandler.mjs'
  9. import EmailHelper from '../Helpers/EmailHelper.mjs'
  10. import UserEmailsConfirmationHandler from './UserEmailsConfirmationHandler.mjs'
  11. import InstitutionsAPI from '../Institutions/InstitutionsAPI.mjs'
  12. import Errors from '../Errors/Errors.js'
  13. import HttpErrorHandler from '../Errors/HttpErrorHandler.mjs'
  14. import { expressify } from '@overleaf/promise-utils'
  15. import AsyncFormHelper from '../Helpers/AsyncFormHelper.mjs'
  16. import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
  17. import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.mjs'
  18. import UserAuditLogHandler from './UserAuditLogHandler.mjs'
  19. import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
  20. import Features from '../../infrastructure/Features.mjs'
  21. import tsscmp from 'tsscmp'
  22. import Modules from '../../infrastructure/Modules.mjs'
  23. const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
  24. const sendConfirmCodeRateLimiter = new RateLimiter('send-confirmation-code', {
  25. points: 1,
  26. duration: 60,
  27. })
  28. const checkConfirmCodeRateLimiter = new RateLimiter(
  29. 'check-confirmation-code-per-email',
  30. {
  31. points: 10,
  32. duration: 60,
  33. }
  34. )
  35. const resendConfirmCodeRateLimiter = new RateLimiter(
  36. 'resend-confirmation-code',
  37. {
  38. points: 1,
  39. duration: 60,
  40. }
  41. )
  42. async function _sendSecurityAlertEmail(user, email) {
  43. const emailOptions = {
  44. to: user.email,
  45. actionDescribed: `a secondary email address has been added to your account ${user.email}`,
  46. message: [
  47. `<span style="display:inline-block;padding: 0 20px;width:100%;">Added: <br/><b>${email}</b></span>`,
  48. ],
  49. action: 'secondary email address added',
  50. }
  51. await EmailHandler.promises.sendEmail('securityAlert', emailOptions)
  52. }
  53. async function sendExistingEmailConfirmationCode(req, res) {
  54. const userId = SessionManager.getLoggedInUserId(req.session)
  55. const email = EmailHelper.parseEmail(req.body.email)
  56. if (!email) {
  57. return res.sendStatus(400)
  58. }
  59. const user = await UserGetter.promises.getUserByAnyEmail(email, {
  60. _id: 1,
  61. email,
  62. })
  63. if (!user || user._id.toString() !== userId) {
  64. return res.sendStatus(422)
  65. }
  66. await sendCodeAndStoreInSession(req, 'pendingExistingEmail', email)
  67. res.sendStatus(204)
  68. }
  69. /**
  70. * This method is for adding a secondary email to be confirmed via a code.
  71. */
  72. async function addWithConfirmationCode(req, res) {
  73. delete req.session.pendingSecondaryEmail
  74. const userId = SessionManager.getLoggedInUserId(req.session)
  75. const email = EmailHelper.parseEmail(req.body.email)
  76. const affiliationOptions = {
  77. university: req.body.university,
  78. role: req.body.role,
  79. department: req.body.department,
  80. }
  81. if (!email) {
  82. return res.sendStatus(422)
  83. }
  84. const user = await UserGetter.promises.getUser(userId, {
  85. email: 1,
  86. 'emails.email': 1,
  87. })
  88. if (user.emails.length >= Settings.emailAddressLimit) {
  89. return res.status(422).json({ message: 'secondary email limit exceeded' })
  90. }
  91. try {
  92. await UserGetter.promises.ensureUniqueEmailAddress(email)
  93. await sendConfirmCodeRateLimiter.consume(email, 1, {
  94. method: 'email',
  95. })
  96. await UserAuditLogHandler.promises.addEntry(
  97. userId,
  98. 'request-add-email-code',
  99. userId,
  100. req.ip,
  101. {
  102. newSecondaryEmail: email,
  103. }
  104. )
  105. await sendCodeAndStoreInSession(
  106. req,
  107. 'pendingSecondaryEmail',
  108. email,
  109. affiliationOptions
  110. )
  111. return res.sendStatus(200)
  112. } catch (err) {
  113. if (err.name === 'EmailExistsError') {
  114. return res.status(409).json({
  115. message: {
  116. type: 'error',
  117. text: req.i18n.translate('email_already_registered'),
  118. },
  119. })
  120. }
  121. if (err?.remainingPoints === 0) {
  122. return res.status(429).json({})
  123. }
  124. logger.err({ err }, 'failed to send confirmation code')
  125. delete req.session.pendingSecondaryEmail
  126. return res.status(500).json({
  127. message: {
  128. key: 'error_performing_request',
  129. },
  130. })
  131. }
  132. }
  133. /**
  134. * @param {import('express').Request} req
  135. * @param {string} sessionKey
  136. * @param {string} email
  137. * @param affiliationOptions
  138. * @returns {Promise<void>}
  139. */
  140. async function sendCodeAndStoreInSession(
  141. req,
  142. sessionKey,
  143. email,
  144. affiliationOptions
  145. ) {
  146. const { confirmCode, confirmCodeExpiresTimestamp } =
  147. await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
  148. email,
  149. false
  150. )
  151. req.session[sessionKey] = {
  152. email,
  153. confirmCode,
  154. confirmCodeExpiresTimestamp,
  155. affiliationOptions,
  156. }
  157. }
  158. /**
  159. * @param {string} sessionKey
  160. * @param {(req: import('express').Request, user: any, email: string, affiliationOptions: any) => Promise<void>} beforeConfirmEmail
  161. * @returns {Promise<*>}
  162. */
  163. const _checkConfirmationCode =
  164. (sessionKey, beforeConfirmEmail) => async (req, res) => {
  165. const userId = SessionManager.getLoggedInUserId(req.session)
  166. const code = req.body.code
  167. const user = await UserGetter.promises.getUser(userId, {
  168. email: 1,
  169. 'emails.email': 1,
  170. })
  171. const sessionData = req.session[sessionKey]
  172. if (!sessionData) {
  173. logger.err({}, `error checking confirmation code. missing ${sessionKey}`)
  174. return res.status(422).json({
  175. message: {
  176. key: 'error_performing_request',
  177. },
  178. })
  179. }
  180. const emailToCheck = sessionData.email
  181. try {
  182. await checkConfirmCodeRateLimiter.consume(emailToCheck, 1, {
  183. method: 'email',
  184. })
  185. } catch (err) {
  186. if (err?.remainingPoints === 0) {
  187. return res.sendStatus(429)
  188. } else {
  189. return res.status(500).json({
  190. message: {
  191. key: 'error_performing_request',
  192. },
  193. })
  194. }
  195. }
  196. if (sessionData.confirmCodeExpiresTimestamp < Date.now()) {
  197. return res.status(403).json({
  198. message: { key: 'expired_confirmation_code' },
  199. })
  200. }
  201. if (!tsscmp(sessionData.confirmCode, code)) {
  202. return res.status(403).json({
  203. message: { key: 'invalid_confirmation_code' },
  204. })
  205. }
  206. try {
  207. await beforeConfirmEmail(
  208. req,
  209. user,
  210. emailToCheck,
  211. sessionData.affiliationOptions
  212. )
  213. await UserUpdater.promises.confirmEmail(
  214. userId,
  215. emailToCheck,
  216. sessionData.affiliationOptions
  217. )
  218. delete req.session[sessionKey]
  219. AnalyticsManager.recordEventForSession(req.session, 'email-verified', {
  220. provider: 'email',
  221. verification_type: 'token',
  222. isPrimary: user.email === emailToCheck,
  223. })
  224. const redirectUrl =
  225. AuthenticationController.getRedirectFromSession(req) || '/project'
  226. return res.json({
  227. redir: redirectUrl,
  228. })
  229. } catch (error) {
  230. if (error.name === 'EmailExistsError') {
  231. return res.status(409).json({
  232. message: {
  233. type: 'error',
  234. text: req.i18n.translate('email_already_registered'),
  235. },
  236. })
  237. }
  238. if (error.name === 'InvalidInstitutionalEmailError') {
  239. return res.status(422).json({
  240. message: {
  241. key: 'email_does_not_belong_to_university',
  242. },
  243. })
  244. }
  245. logger.err({ error }, 'failed to check confirmation code')
  246. return res.status(500).json({
  247. message: {
  248. key: 'error_performing_request',
  249. },
  250. })
  251. }
  252. }
  253. const checkNewSecondaryEmailConfirmationCode = _checkConfirmationCode(
  254. 'pendingSecondaryEmail',
  255. async (req, user, email, affiliationOptions) => {
  256. await UserAuditLogHandler.promises.addEntry(
  257. user._id,
  258. 'add-email-via-code',
  259. user._id,
  260. req.ip,
  261. { newSecondaryEmail: email }
  262. )
  263. await UserUpdater.promises.addEmailAddress(
  264. user._id,
  265. email,
  266. affiliationOptions,
  267. {
  268. initiatorId: user._id,
  269. ipAddress: req.ip,
  270. }
  271. )
  272. await _sendSecurityAlertEmail(user, email)
  273. }
  274. )
  275. const checkExistingEmailConfirmationCode = _checkConfirmationCode(
  276. 'pendingExistingEmail',
  277. async (req, user, email) => {
  278. await UserAuditLogHandler.promises.addEntry(
  279. user._id,
  280. 'confirm-email-via-code',
  281. user._id,
  282. req.ip,
  283. { email }
  284. )
  285. }
  286. )
  287. const _resendConfirmationCode =
  288. (sessionKey, operation, auditLogEmailKey) => async (req, res) => {
  289. const sessionData = req.session[sessionKey]
  290. if (!sessionData) {
  291. logger.err({}, `error resending confirmation code. missing ${sessionKey}`)
  292. return res.status(422).json({
  293. message: {
  294. key: 'error_performing_request',
  295. },
  296. })
  297. }
  298. const email = sessionData.email
  299. try {
  300. await resendConfirmCodeRateLimiter.consume(email, 1, { method: 'email' })
  301. } catch (err) {
  302. if (err?.remainingPoints === 0) {
  303. return res.status(429).json({})
  304. } else {
  305. throw err
  306. }
  307. }
  308. const userId = SessionManager.getLoggedInUserId(req.session)
  309. try {
  310. await UserAuditLogHandler.promises.addEntry(
  311. userId,
  312. operation,
  313. userId,
  314. req.ip,
  315. { [auditLogEmailKey]: email }
  316. )
  317. const { confirmCode, confirmCodeExpiresTimestamp } =
  318. await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
  319. email,
  320. false
  321. )
  322. sessionData.confirmCode = confirmCode
  323. sessionData.confirmCodeExpiresTimestamp = confirmCodeExpiresTimestamp
  324. return res.status(200).json({ message: { key: 'we_sent_new_code' } })
  325. } catch (err) {
  326. logger.err({ err, userId, email }, 'failed to send confirmation code')
  327. return res.status(500).json({ key: 'error_performing_request' })
  328. }
  329. }
  330. const resendNewSecondaryEmailConfirmationCode = _resendConfirmationCode(
  331. 'pendingSecondaryEmail',
  332. 'resend-add-email-code',
  333. 'newSecondaryEmail'
  334. )
  335. const resendExistingSecondaryEmailConfirmationCode = _resendConfirmationCode(
  336. 'pendingExistingEmail',
  337. 'resend-confirm-email-code',
  338. 'email'
  339. )
  340. async function confirmSecondaryEmailPage(req, res) {
  341. if (!req.session.pendingSecondaryEmail) {
  342. const redirectURL =
  343. AuthenticationController.getRedirectFromSession(req) || '/project'
  344. return res.redirect(redirectURL)
  345. }
  346. AnalyticsManager.recordEventForSession(
  347. req.session,
  348. 'confirm-secondary-email-page-displayed'
  349. )
  350. res.render('user/confirmSecondaryEmail', {
  351. email: req.session.pendingSecondaryEmail.email,
  352. })
  353. }
  354. async function addSecondaryEmailPage(req, res) {
  355. const userId = SessionManager.getLoggedInUserId(req.session)
  356. const confirmedEmails =
  357. await UserGetter.promises.getUserConfirmedEmails(userId)
  358. if (confirmedEmails.length >= 2) {
  359. const redirectURL =
  360. AuthenticationController.getRedirectFromSession(req) || '/project'
  361. return res.redirect(redirectURL)
  362. }
  363. AnalyticsManager.recordEventForSession(
  364. req.session,
  365. 'add-secondary-email-page-displayed'
  366. )
  367. res.render('user/addSecondaryEmail')
  368. }
  369. async function primaryEmailCheckPage(req, res) {
  370. const userId = SessionManager.getLoggedInUserId(req.session)
  371. const user = await UserGetter.promises.getUser(userId, {
  372. lastPrimaryEmailCheck: 1,
  373. signUpDate: 1,
  374. email: 1,
  375. emails: 1,
  376. })
  377. if (!UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck(user)) {
  378. return res.redirect('/project')
  379. }
  380. AnalyticsManager.recordEventForSession(
  381. req.session,
  382. 'primary-email-check-page-displayed'
  383. )
  384. res.render('user/primaryEmailCheck')
  385. }
  386. async function primaryEmailCheck(req, res) {
  387. const userId = SessionManager.getLoggedInUserId(req.session)
  388. await UserUpdater.promises.updateUser(userId, {
  389. $set: { lastPrimaryEmailCheck: new Date() },
  390. })
  391. AnalyticsManager.recordEventForSession(
  392. req.session,
  393. 'primary-email-check-done'
  394. )
  395. // We want to redirect to prompt a user to add a secondary email if their primary
  396. // is an institutional email and they dont' already have a secondary.
  397. if (Features.hasFeature('saas') && req.capabilitySet.has('add-affiliation')) {
  398. const confirmedEmails =
  399. await UserGetter.promises.getUserConfirmedEmails(userId)
  400. if (confirmedEmails.length < 2) {
  401. const { email: primaryEmail } = SessionManager.getSessionUser(req.session)
  402. const primaryEmailDomain = EmailHelper.getDomain(primaryEmail)
  403. const institution = (
  404. await Modules.promises.hooks.fire(
  405. 'getInstitutionViaDomain',
  406. primaryEmailDomain
  407. )
  408. )?.[0]
  409. if (institution) {
  410. return AsyncFormHelper.redirect(req, res, '/user/emails/add-secondary')
  411. }
  412. }
  413. }
  414. AsyncFormHelper.redirect(req, res, '/project')
  415. }
  416. async function showConfirm(req, res, next) {
  417. res.render('user/confirm_email', {
  418. token: req.query.token,
  419. title: 'confirm_email',
  420. })
  421. }
  422. async function remove(req, res) {
  423. const userId = SessionManager.getLoggedInUserId(req.session)
  424. const email = EmailHelper.parseEmail(req.body.email)
  425. if (!email) {
  426. return res.sendStatus(422)
  427. }
  428. const auditLog = {
  429. initiatorId: userId,
  430. ipAddress: req.ip,
  431. }
  432. await UserUpdater.promises.removeEmailAddress(userId, email, auditLog)
  433. res.sendStatus(200)
  434. }
  435. async function setDefault(req, res, next) {
  436. const userId = SessionManager.getLoggedInUserId(req.session)
  437. const email = EmailHelper.parseEmail(req.body.email)
  438. if (!email) {
  439. return res.sendStatus(422)
  440. }
  441. const { emails, email: oldDefault } = await UserGetter.promises.getUser(
  442. userId,
  443. { email: 1, emails: 1 }
  444. )
  445. const primaryEmailData = emails?.find(email => email.email === oldDefault)
  446. const deleteOldEmail =
  447. req.query['delete-unconfirmed-primary'] !== undefined &&
  448. primaryEmailData &&
  449. !primaryEmailData.confirmedAt
  450. const auditLog = {
  451. initiatorId: userId,
  452. ipAddress: req.ip,
  453. }
  454. try {
  455. await UserUpdater.promises.setDefaultEmailAddress(
  456. userId,
  457. email,
  458. false,
  459. auditLog,
  460. true,
  461. deleteOldEmail
  462. )
  463. } catch (err) {
  464. return UserEmailsController._handleEmailError(err, req, res, next)
  465. }
  466. SessionManager.setInSessionUser(req.session, { email })
  467. const user = SessionManager.getSessionUser(req.session)
  468. try {
  469. await UserSessionsManager.promises.removeSessionsFromRedis(
  470. user,
  471. req.sessionID // remove all sessions except the current session
  472. )
  473. } catch (err) {
  474. logger.warn(
  475. { err },
  476. 'failed revoking secondary sessions after changing default email'
  477. )
  478. }
  479. if (
  480. req.query['delete-unconfirmed-primary'] !== undefined &&
  481. primaryEmailData &&
  482. !primaryEmailData.confirmedAt
  483. ) {
  484. await UserUpdater.promises.removeEmailAddress(
  485. userId,
  486. primaryEmailData.email,
  487. {
  488. initiatorId: userId,
  489. ipAddress: req.ip,
  490. extraInfo: {
  491. info: 'removed unconfirmed email after setting new primary',
  492. },
  493. }
  494. )
  495. }
  496. res.sendStatus(200)
  497. }
  498. const UserEmailsController = {
  499. list(req, res, next) {
  500. const userId = SessionManager.getLoggedInUserId(req.session)
  501. UserGetter.getUserFullEmails(userId, function (error, fullEmails) {
  502. if (error) {
  503. return next(error)
  504. }
  505. res.json(fullEmails)
  506. })
  507. },
  508. addWithConfirmationCode: expressify(addWithConfirmationCode),
  509. checkNewSecondaryEmailConfirmationCode: expressify(
  510. checkNewSecondaryEmailConfirmationCode
  511. ),
  512. checkExistingEmailConfirmationCode: expressify(
  513. checkExistingEmailConfirmationCode
  514. ),
  515. resendNewSecondaryEmailConfirmationCode: expressify(
  516. resendNewSecondaryEmailConfirmationCode
  517. ),
  518. resendExistingSecondaryEmailConfirmationCode: expressify(
  519. resendExistingSecondaryEmailConfirmationCode
  520. ),
  521. remove: expressify(remove),
  522. setDefault: expressify(setDefault),
  523. endorse(req, res, next) {
  524. const userId = SessionManager.getLoggedInUserId(req.session)
  525. const email = EmailHelper.parseEmail(req.body.email)
  526. if (!email) {
  527. return res.sendStatus(422)
  528. }
  529. InstitutionsAPI.endorseAffiliation(
  530. userId,
  531. email,
  532. req.body.role,
  533. req.body.department,
  534. function (error) {
  535. if (error) {
  536. return next(error)
  537. }
  538. res.sendStatus(204)
  539. }
  540. )
  541. },
  542. sendExistingEmailConfirmationCode: expressify(
  543. sendExistingEmailConfirmationCode
  544. ),
  545. addSecondaryEmailPage: expressify(addSecondaryEmailPage),
  546. confirmSecondaryEmailPage: expressify(confirmSecondaryEmailPage),
  547. primaryEmailCheckPage: expressify(primaryEmailCheckPage),
  548. primaryEmailCheck: expressify(primaryEmailCheck),
  549. showConfirm: expressify(showConfirm),
  550. confirm(req, res, next) {
  551. const { token } = req.body
  552. if (!token) {
  553. return res.status(422).json({
  554. message: req.i18n.translate('confirmation_link_broken'),
  555. })
  556. }
  557. UserEmailsConfirmationHandler.confirmEmailFromToken(
  558. req,
  559. token,
  560. function (error, userData) {
  561. if (error) {
  562. if (error instanceof Errors.ForbiddenError) {
  563. res.status(403).json({
  564. message: {
  565. key: 'confirm-email-wrong-user',
  566. text: `We can’t confirm this email. You must be logged in with the Overleaf account that requested the new secondary email.`,
  567. },
  568. })
  569. } else if (error instanceof Errors.NotFoundError) {
  570. res.status(404).json({
  571. message: req.i18n.translate('confirmation_token_invalid'),
  572. })
  573. } else {
  574. next(error)
  575. }
  576. } else {
  577. const { userId, email } = userData
  578. const tokenPrefix = token.substring(0, AUDIT_LOG_TOKEN_PREFIX_LENGTH)
  579. UserAuditLogHandler.addEntry(
  580. userId,
  581. 'confirm-email',
  582. userId,
  583. req.ip,
  584. { token: tokenPrefix, email },
  585. auditLogError => {
  586. if (auditLogError) {
  587. logger.error(
  588. { error: auditLogError, userId, token: tokenPrefix },
  589. 'failed to add audit log entry'
  590. )
  591. }
  592. UserGetter.getUser(
  593. userData.userId,
  594. { _id: 1, email: 1, analyticsId: 1, labsProgram: 1 },
  595. function (error, user) {
  596. if (error) {
  597. logger.error(
  598. { error, userId: userData.userId },
  599. 'failed to get user'
  600. )
  601. }
  602. const isPrimary = user?.email === userData.email
  603. AnalyticsManager.recordEventForMongoUserInBackground(
  604. user,
  605. 'email-verified',
  606. {
  607. provider: 'email',
  608. verification_type: 'link',
  609. isPrimary,
  610. }
  611. )
  612. res.sendStatus(200)
  613. }
  614. )
  615. }
  616. )
  617. }
  618. }
  619. )
  620. },
  621. _handleEmailError(error, req, res, next) {
  622. if (error instanceof Errors.UnconfirmedEmailError) {
  623. return HttpErrorHandler.conflict(req, res, 'email must be confirmed')
  624. } else if (error instanceof Errors.EmailExistsError) {
  625. const message = req.i18n.translate('email_already_registered')
  626. return HttpErrorHandler.conflict(req, res, message)
  627. } else if (error.message === '422: Email does not belong to university') {
  628. const message = req.i18n.translate('email_does_not_belong_to_university')
  629. return HttpErrorHandler.conflict(req, res, message)
  630. }
  631. next(error)
  632. },
  633. }
  634. export default UserEmailsController