CaptchaMiddleware.mjs 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import { fetchJson } from '@overleaf/fetch-utils'
  2. import logger from '@overleaf/logger'
  3. import Settings from '@overleaf/settings'
  4. import Metrics from '@overleaf/metrics'
  5. import OError from '@overleaf/o-error'
  6. import DeviceHistory from './DeviceHistory.mjs'
  7. import AuthenticationController from '../Authentication/AuthenticationController.mjs'
  8. import { expressify } from '@overleaf/promise-utils'
  9. import EmailsHelper from '../Helpers/EmailHelper.mjs'
  10. function respondInvalidCaptcha(req, res) {
  11. res.status(400).json({
  12. errorReason: 'cannot_verify_user_not_robot',
  13. message: {
  14. text: req.i18n.translate('cannot_verify_user_not_robot'),
  15. },
  16. })
  17. }
  18. async function initializeDeviceHistory(req) {
  19. req.deviceHistory = new DeviceHistory()
  20. try {
  21. await req.deviceHistory.parse(req)
  22. } catch (err) {
  23. logger.err({ err }, 'cannot parse deviceHistory')
  24. }
  25. }
  26. async function canSkipCaptcha(req, res) {
  27. const trustedUser =
  28. req.body?.email &&
  29. (Settings.recaptcha.trustedUsers.includes(req.body.email) ||
  30. Settings.recaptcha.trustedUsersRegex?.test(req.body.email))
  31. if (trustedUser) {
  32. return res.json(true)
  33. }
  34. await initializeDeviceHistory(req)
  35. const canSkip = req.deviceHistory.has(req.body?.email)
  36. Metrics.inc('captcha_pre_flight', 1, {
  37. status: canSkip ? 'skipped' : 'missing',
  38. })
  39. res.json(canSkip)
  40. }
  41. function validateCaptcha(action) {
  42. return expressify(async function (req, res, next) {
  43. const email = EmailsHelper.parseEmail(req.body?.email)
  44. const trustedUser =
  45. email &&
  46. (Settings.recaptcha.trustedUsers.includes(email) ||
  47. Settings.recaptcha.trustedUsersRegex?.test(email))
  48. if (!Settings.recaptcha?.siteKey || Settings.recaptcha.disabled[action]) {
  49. if (action === 'login') {
  50. AuthenticationController.setAuditInfo(req, { captcha: 'disabled' })
  51. }
  52. Metrics.inc('captcha', 1, { path: action, status: 'disabled' })
  53. return next()
  54. }
  55. if (trustedUser) {
  56. if (action === 'login') {
  57. AuthenticationController.setAuditInfo(req, { captcha: 'trusted' })
  58. }
  59. Metrics.inc('captcha', 1, { path: action, status: 'trusted' })
  60. return next()
  61. }
  62. const reCaptchaResponse = req.body['g-recaptcha-response']
  63. if (action === 'login') {
  64. await initializeDeviceHistory(req)
  65. const fromKnownDevice = req.deviceHistory.has(email)
  66. AuthenticationController.setAuditInfo(req, { fromKnownDevice })
  67. if (!reCaptchaResponse && fromKnownDevice) {
  68. // The user has previously logged in from this device, which required
  69. // solving a captcha or keeping the device history alive.
  70. // We can skip checking the (missing) captcha response.
  71. AuthenticationController.setAuditInfo(req, { captcha: 'skipped' })
  72. Metrics.inc('captcha', 1, { path: action, status: 'skipped' })
  73. return next()
  74. }
  75. }
  76. if (!reCaptchaResponse) {
  77. Metrics.inc('captcha', 1, { path: action, status: 'missing' })
  78. return respondInvalidCaptcha(req, res)
  79. }
  80. let body
  81. try {
  82. body = await fetchJson(Settings.recaptcha.endpoint, {
  83. method: 'POST',
  84. body: new URLSearchParams([
  85. ['secret', Settings.recaptcha.secretKey],
  86. ['response', reCaptchaResponse],
  87. ]),
  88. })
  89. } catch (err) {
  90. Metrics.inc('captcha', 1, { path: action, status: 'error' })
  91. throw OError.tag(err, 'failed recaptcha siteverify request', {
  92. body: err.body,
  93. })
  94. }
  95. if (!body.success) {
  96. logger.warn(
  97. { statusCode: 200, body },
  98. 'failed recaptcha siteverify request'
  99. )
  100. Metrics.inc('captcha', 1, { path: action, status: 'failed' })
  101. return respondInvalidCaptcha(req, res)
  102. }
  103. Metrics.inc('captcha', 1, { path: action, status: 'solved' })
  104. if (action === 'login') {
  105. AuthenticationController.setAuditInfo(req, { captcha: 'solved' })
  106. }
  107. next()
  108. })
  109. }
  110. export default {
  111. respondInvalidCaptcha,
  112. validateCaptcha,
  113. canSkipCaptcha: expressify(canSkipCaptcha),
  114. }