Просмотр исходного кода

Merge pull request #6417 from overleaf/jpa-device-history

[web] add cookie/JWE based device history for skipping captcha challenge

GitOrigin-RevId: b091564bfd93f7e587d396c860fd864f220f4b63
Jakob Ackermann 4 лет назад
Родитель
Сommit
8e77ada424

+ 11 - 1
services/web/app/src/Features/Authentication/AuthenticationController.js

@@ -530,7 +530,17 @@ function _afterLoginSessionSetup(req, user, callback) {
           return callback(err)
         }
         UserSessionsManager.trackSession(user, req.sessionID, function () {})
-        callback(null)
+        if (!req.deviceHistory) {
+          // Captcha disabled or SSO-based login.
+          return callback()
+        }
+        req.deviceHistory.add(user.email)
+        req.deviceHistory
+          .serialize(req.res)
+          .catch(err => {
+            logger.err({ err }, 'cannot serialize deviceHistory')
+          })
+          .finally(() => callback())
       })
     })
   })

+ 91 - 54
services/web/app/src/Features/Captcha/CaptchaMiddleware.js

@@ -1,63 +1,100 @@
-/* eslint-disable
-    max-len,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-let CaptchaMiddleware
-const request = require('request')
+const request = require('request-promise-native')
 const logger = require('@overleaf/logger')
 const Settings = require('@overleaf/settings')
+const Metrics = require('@overleaf/metrics')
+const DeviceHistory = require('./DeviceHistory')
+const AuthenticationController = require('../Authentication/AuthenticationController')
+const { expressify } = require('../../util/promises')
 
-module.exports = CaptchaMiddleware = {
-  validateCaptcha(action) {
-    return function (req, res, next) {
-      if (
-        (Settings.recaptcha != null ? Settings.recaptcha.siteKey : undefined) ==
-        null
-      ) {
-        return next()
-      }
-      if (Settings.recaptcha.disabled[action]) {
+function respondInvalidCaptcha(res) {
+  res.status(400).json({
+    errorReason: 'cannot_verify_user_not_robot',
+    message: {
+      text: 'Sorry, we could not verify that you are not a robot. Please check that Google reCAPTCHA is not being blocked by an ad blocker or firewall.',
+    },
+  })
+}
+
+async function initializeDeviceHistory(req) {
+  req.deviceHistory = new DeviceHistory()
+  try {
+    await req.deviceHistory.parse(req)
+  } catch (err) {
+    logger.err({ err }, 'cannot parse deviceHistory')
+  }
+}
+
+async function canSkipCaptcha(req, res) {
+  await initializeDeviceHistory(req)
+  const canSkip = req.deviceHistory.has(req.body?.email)
+  Metrics.inc('captcha_pre_flight', 1, {
+    status: canSkip ? 'skipped' : 'missing',
+  })
+  res.json(canSkip)
+}
+
+function validateCaptcha(action) {
+  return expressify(async function (req, res, next) {
+    if (!Settings.recaptcha?.siteKey || Settings.recaptcha.disabled[action]) {
+      Metrics.inc('captcha', 1, { path: action, status: 'disabled' })
+      return next()
+    }
+    if (action === 'login') {
+      await initializeDeviceHistory(req)
+      if (req.deviceHistory.has(req.body?.email)) {
+        // The user has previously logged in from this device, which required
+        //  solving a captcha or keeping the device history alive.
+        // We can skip checking the (potentially missing) captcha response.
+        AuthenticationController.setAuditInfo(req, { captcha: 'skipped' })
+        Metrics.inc('captcha', 1, { path: action, status: 'skipped' })
         return next()
       }
-      const response = req.body['g-recaptcha-response']
-      const options = {
-        form: {
-          secret: Settings.recaptcha.secretKey,
-          response,
-        },
-        json: true,
+    }
+    const reCaptchaResponse = req.body['g-recaptcha-response']
+    if (!reCaptchaResponse) {
+      Metrics.inc('captcha', 1, { path: action, status: 'missing' })
+      return respondInvalidCaptcha(res)
+    }
+    const options = {
+      method: 'POST',
+      url: Settings.recaptcha.endpoint,
+      form: {
+        secret: Settings.recaptcha.secretKey,
+        response: reCaptchaResponse,
+      },
+      json: true,
+    }
+    let body
+    try {
+      body = await request(options)
+    } catch (err) {
+      const response = err.response
+      if (response) {
+        logger.warn(
+          { statusCode: response.statusCode, body: err.body },
+          'failed recaptcha siteverify request'
+        )
       }
-      return request.post(
-        'https://www.google.com/recaptcha/api/siteverify',
-        options,
-        function (error, response, body) {
-          if (error != null) {
-            return next(error)
-          }
-          if (!(body != null ? body.success : undefined)) {
-            logger.warn(
-              { statusCode: response.statusCode, body },
-              'failed recaptcha siteverify request'
-            )
-            return res.status(400).json({
-              errorReason: 'cannot_verify_user_not_robot',
-              message: {
-                text: 'Sorry, we could not verify that you are not a robot. Please check that Google reCAPTCHA is not being blocked by an ad blocker or firewall.',
-              },
-            })
-          } else {
-            return next()
-          }
-        }
+      Metrics.inc('captcha', 1, { path: action, status: 'error' })
+      return next(err)
+    }
+    if (!body?.success) {
+      logger.warn(
+        { statusCode: 200, body },
+        'failed recaptcha siteverify request'
       )
+      Metrics.inc('captcha', 1, { path: action, status: 'failed' })
+      return respondInvalidCaptcha(res)
+    }
+    Metrics.inc('captcha', 1, { path: action, status: 'solved' })
+    if (action === 'login') {
+      AuthenticationController.setAuditInfo(req, { captcha: 'solved' })
     }
-  },
+    next()
+  })
+}
+
+module.exports = {
+  validateCaptcha,
+  canSkipCaptcha: expressify(canSkipCaptcha),
 }

+ 103 - 0
services/web/app/src/Features/Captcha/DeviceHistory.js

@@ -0,0 +1,103 @@
+const crypto = require('crypto')
+const jose = require('jose')
+const Metrics = require('@overleaf/metrics')
+const Settings = require('@overleaf/settings')
+
+const COOKIE_NAME = Settings.deviceHistory.cookieName
+const ENTRY_EXPIRY = Settings.deviceHistory.entryExpiry
+const MAX_ENTRIES = Settings.deviceHistory.maxEntries
+
+let SECRET
+if (Settings.deviceHistory.secret) {
+  SECRET = crypto.createSecretKey(
+    Buffer.from(Settings.deviceHistory.secret, 'hex')
+  )
+}
+const CONTENT_ENCRYPTION_ALGORITHM = 'A256GCM'
+const KEY_MANAGEMENT_ALGORITHM = 'A256GCMKW'
+const ENCRYPTION_HEADER = {
+  alg: KEY_MANAGEMENT_ALGORITHM,
+  enc: CONTENT_ENCRYPTION_ALGORITHM,
+}
+const DECRYPTION_OPTIONS = {
+  contentEncryptionAlgorithms: [CONTENT_ENCRYPTION_ALGORITHM],
+  keyManagementAlgorithms: [KEY_MANAGEMENT_ALGORITHM],
+}
+
+const ENCODER = new TextEncoder()
+const DECODER = new TextDecoder()
+
+class DeviceHistory {
+  constructor() {
+    this.entries = []
+  }
+
+  has(email) {
+    return this.entries.some(entry => entry.e === email)
+  }
+
+  add(email) {
+    // Entries are sorted by age, starting from oldest (idx 0) to newest.
+    // When parsing/serializing we are looking at the last n=MAX_ENTRIES entries
+    //  from the list and discard any other stale entries.
+    this.entries = this.entries.filter(entry => entry.e !== email)
+    this.entries.push({ e: email, t: Date.now() })
+  }
+
+  async serialize(res) {
+    let v = ''
+    if (this.entries.length > 0 && SECRET) {
+      v = await new jose.CompactEncrypt(
+        ENCODER.encode(JSON.stringify(this.entries.slice(-MAX_ENTRIES)))
+      )
+        .setProtectedHeader(ENCRYPTION_HEADER)
+        .encrypt(SECRET)
+    }
+
+    const options = {
+      domain: Settings.cookieDomain,
+      maxAge: ENTRY_EXPIRY,
+      secure: Settings.secureCookie,
+      sameSite: Settings.sameSiteCookie,
+      httpOnly: true,
+      path: '/login',
+    }
+    if (v) {
+      res.cookie(COOKIE_NAME, v, options)
+    } else {
+      options.maxAge = -1
+      res.clearCookie(COOKIE_NAME, options)
+    }
+  }
+
+  async parse(req) {
+    const blob = req.cookies[COOKIE_NAME]
+    if (!blob || !SECRET) {
+      Metrics.inc('device_history', 1, { status: 'missing' })
+      return
+    }
+    try {
+      const { plaintext } = await jose.compactDecrypt(
+        blob,
+        SECRET,
+        DECRYPTION_OPTIONS
+      )
+      const minTimestamp = Date.now() - ENTRY_EXPIRY
+      this.entries = JSON.parse(DECODER.decode(plaintext))
+        .slice(-MAX_ENTRIES)
+        .filter(entry => entry.t > minTimestamp)
+    } catch (err) {
+      Metrics.inc('device_history', 1, { status: 'failure' })
+      throw err
+    }
+    if (this.entries.length === MAX_ENTRIES) {
+      // Track hitting the limit, we might need to increase the limit.
+      Metrics.inc('device_history_at_limit')
+    }
+    // Collect quantiles of the size
+    Metrics.summary('device_history_size', this.entries.length)
+    Metrics.inc('device_history', 1, { status: 'success' })
+  }
+}
+
+module.exports = DeviceHistory

+ 18 - 1
services/web/app/src/router.js

@@ -52,6 +52,7 @@ const SystemMessageController = require('./Features/SystemMessages/SystemMessage
 const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware')
 const AnalyticsUTMTrackingMiddleware = require('./Features/Analytics/AnalyticsUTMTrackingMiddleware')
 const SplitTestMiddleware = require('./Features/SplitTests/SplitTestMiddleware')
+const CaptchaMiddleware = require('./Features/Captcha/CaptchaMiddleware')
 const { Joi, validate } = require('./infrastructure/Validation')
 const {
   renderUnsupportedBrowserPage,
@@ -81,10 +82,26 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
     )
   )
 
+  // Mount onto /login in order to get the deviceHistory cookie.
+  webRouter.post(
+    '/login/can-skip-captcha',
+    // Keep in sync with the overleaf-login options.
+    RateLimiterMiddleware.rateLimit({
+      endpointName: 'can-skip-captcha',
+      maxRequests: 20,
+      timeInterval: 60,
+    }),
+    CaptchaMiddleware.canSkipCaptcha
+  )
+
   webRouter.get('/login', UserPagesController.loginPage)
   AuthenticationController.addEndpointToLoginWhitelist('/login')
 
-  webRouter.post('/login', AuthenticationController.passportLogin)
+  webRouter.post(
+    '/login',
+    CaptchaMiddleware.validateCaptcha('login'),
+    AuthenticationController.passportLogin
+  )
 
   if (Settings.enableLegacyLogin) {
     AuthenticationController.addEndpointToLoginWhitelist('/login/legacy')

+ 12 - 0
services/web/config/settings.defaults.js

@@ -435,6 +435,15 @@ module.exports = {
     },
   },
 
+  deviceHistory: {
+    cookieName: process.env.DEVICE_HISTORY_COOKIE_NAME || 'deviceHistory',
+    entryExpiry:
+      parseInt(process.env.DEVICE_HISTORY_ENTRY_EXPIRY_MS, 10) ||
+      30 * 24 * 60 * 60 * 1000,
+    maxEntries: parseInt(process.env.DEVICE_HISTORY_MAX_ENTRIES, 10) || 10,
+    secret: process.env.DEVICE_HISTORY_SECRET,
+  },
+
   // Email support
   // -------------
   //
@@ -595,6 +604,9 @@ module.exports = {
   //   header_extras: [{text: "Some Page", url: "http://example.com/some/page", class: "subdued"}]
 
   recaptcha: {
+    endpoint:
+      process.env.RECAPTCHA_ENDPOINT ||
+      'https://www.google.com/recaptcha/api/siteverify',
     disabled: {
       invite: true,
       login: true,

+ 5 - 0
services/web/docker-compose.common.env

@@ -13,6 +13,7 @@ PUBLIC_URL=http://www.overleaf.test:3000
 HTTP_TEST_HOST=www.overleaf.test
 OT_JWT_AUTH_KEY=very secret key
 EXTERNAL_AUTH=none
+RECAPTCHA_ENDPOINT=http://localhost:2222/recaptcha/api/siteverify
 # Server-Pro LDAP
 SHARELATEX_LDAP_URL=ldap://ldap:389
 SHARELATEX_LDAP_SEARCH_BASE=ou=people,dc=planetexpress,dc=com
@@ -34,3 +35,7 @@ SHARELATEX_SAML_LAST_NAME_FIELD=sn
 SHARELATEX_SAML_UPDATE_USER_DETAILS_ON_LOGIN=true
 # simplesaml cert from https://github.com/overleaf/google-ops/tree/master/docker-images/saml-test/var-simplesamlphp/cert
 SHARELATEX_SAML_CERT=MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ==
+# DEVICE_HISTORY_SECRET has been generated using:
+# NOTE: crypto.generateKeySync was added in v15, v16 is the next LTS release.
+# $ docker run --rm node:16 --print 'require("crypto").generateKeySync("aes", { length: 256 }).export().toString("hex")'
+DEVICE_HISTORY_SECRET=1b46e6cdf72db02845da06c9517c9cfbbfa0d87357479f4e1df3ce160bd54807

+ 23 - 0
services/web/frontend/js/features/form-helpers/captcha.js

@@ -1,8 +1,31 @@
+import { postJSON } from '../../infrastructure/fetch-json'
+
 const grecaptcha = window.grecaptcha
 
 let recaptchaId
 const recaptchaCallbacks = []
 
+export async function canSkipCaptcha(email) {
+  const controller = new AbortController()
+  const signal = controller.signal
+  const timer = setTimeout(() => {
+    controller.abort()
+  }, 1000)
+  let canSkip
+  try {
+    canSkip = await postJSON('/login/can-skip-captcha', {
+      signal,
+      body: { email },
+      swallowAbortError: false,
+    })
+  } catch (e) {
+    canSkip = false
+  } finally {
+    clearTimeout(timer)
+  }
+  return canSkip
+}
+
 export async function validateCaptchaV2() {
   if (
     // Detect blocked recaptcha

+ 28 - 4
services/web/frontend/js/features/form-helpers/hydrate-form.js

@@ -1,6 +1,6 @@
 import classNames from 'classnames'
 import { FetchError, postJSON } from '../../infrastructure/fetch-json'
-import { validateCaptchaV2 } from './captcha'
+import { canSkipCaptcha, validateCaptchaV2 } from './captcha'
 import inputValidator from './input-validator'
 import { disableElement, enableElement } from '../utils/disableElement'
 
@@ -22,9 +22,22 @@ function formSubmitHelper(formEl) {
     const messageBag = []
 
     try {
-      const captchaResponse = await validateCaptcha(formEl)
-
-      const data = await sendFormRequest(formEl, captchaResponse)
+      let data
+      try {
+        const captchaResponse = await validateCaptcha(formEl)
+        data = await sendFormRequest(formEl, captchaResponse)
+      } catch (e) {
+        if (
+          e instanceof FetchError &&
+          e.data?.errorReason === 'cannot_verify_user_not_robot'
+        ) {
+          // Trigger captcha unconditionally.
+          const captchaResponse = await validateCaptchaV2()
+          data = await sendFormRequest(formEl, captchaResponse)
+        } else {
+          throw e
+        }
+      }
       formEl.dispatchEvent(new Event('sent'))
 
       // Handle redirects
@@ -65,6 +78,17 @@ function formSubmitHelper(formEl) {
 async function validateCaptcha(formEl) {
   let captchaResponse
   if (formEl.hasAttribute('captcha')) {
+    if (
+      formEl.getAttribute('action') === '/login' &&
+      (await canSkipCaptcha(new FormData(formEl).get('email')))
+    ) {
+      // The email is present in the deviceHistory, and we can skip the display
+      //  of a captcha challenge.
+      // The actual login POST request will be checked against the deviceHistory
+      //  again and the server can trigger the display of a captcha if needed by
+      //  sending a 400 with errorReason set to 'cannot_verify_user_not_robot'.
+      return ''
+    }
     captchaResponse = await validateCaptchaV2()
   }
   return captchaResponse

+ 12 - 9
services/web/frontend/js/infrastructure/fetch-json.js

@@ -9,6 +9,7 @@ import OError from '@overleaf/o-error'
  * @typedef {Object} FetchOptions
  * @extends RequestInit
  * @property {Object} body
+ * @property {Boolean} swallowAbortError Set to false for throwing AbortErrors.
  */
 
 /**
@@ -128,6 +129,7 @@ function fetchJSON(
     headers = {},
     method = 'GET',
     credentials = 'same-origin',
+    swallowAbortError = true,
     ...otherOptions
   }
 ) {
@@ -187,16 +189,17 @@ function fetchJSON(
       },
       error => {
         // swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
-        if (error.name !== 'AbortError') {
-          // the fetch failed
-          reject(
-            new FetchError(
-              'There was an error fetching the JSON',
-              path,
-              options
-            ).withCause(error)
-          )
+        if (swallowAbortError && error.name === 'AbortError') {
+          return
         }
+        // the fetch failed
+        reject(
+          new FetchError(
+            'There was an error fetching the JSON',
+            path,
+            options
+          ).withCause(error)
+        )
       }
     )
   })

+ 12 - 7
services/web/package-lock.json

@@ -21411,7 +21411,7 @@
     "find-up": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
-      "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==",
+      "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
       "dev": true,
       "requires": {
         "locate-path": "^2.0.0"
@@ -25127,6 +25127,11 @@
       "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz",
       "integrity": "sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w=="
     },
+    "jose": {
+      "version": "4.3.8",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-4.3.8.tgz",
+      "integrity": "sha512-dFiqN5FPLNWa/v+J3ShFjV/9sRGickxMbGUbqBrYr+BkrqLOieACaavSi9XmLJXe0Uzd7Cgs1oYtDvDrOyWLgw=="
+    },
     "jquery": {
       "version": "2.2.4",
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz",
@@ -26506,7 +26511,7 @@
     "levn": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
-      "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+      "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
       "dev": true,
       "requires": {
         "prelude-ls": "~1.1.2",
@@ -26565,7 +26570,7 @@
     "locate-path": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
-      "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==",
+      "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
       "dev": true,
       "requires": {
         "p-locate": "^2.0.0",
@@ -30109,7 +30114,7 @@
     "p-locate": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
-      "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==",
+      "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
       "dev": true,
       "requires": {
         "p-limit": "^1.1.0"
@@ -30127,7 +30132,7 @@
         "p-try": {
           "version": "1.0.0",
           "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
-          "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==",
+          "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
           "dev": true
         }
       }
@@ -31645,7 +31650,7 @@
     "prelude-ls": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
-      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
+      "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
       "dev": true
     },
     "prepend-http": {
@@ -38050,7 +38055,7 @@
     "type-check": {
       "version": "0.3.2",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
-      "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+      "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==",
       "dev": true,
       "requires": {
         "prelude-ls": "~1.1.2"

+ 2 - 1
services/web/package.json

@@ -129,6 +129,7 @@
     "i18next-fs-backend": "^1.0.7",
     "i18next-http-middleware": "^3.0.2",
     "isomorphic-unfetch": "^3.0.0",
+    "jose": "^4.3.8",
     "jquery": "^2.2.4",
     "json2csv": "^4.3.3",
     "jsonwebtoken": "^8.5.1",
@@ -291,4 +292,4 @@
     "webpack-merge": "^4.2.2",
     "worker-loader": "^2.0.0"
   }
-}
+}

+ 10 - 0
services/web/test/acceptance/config/settings.test.defaults.js

@@ -194,6 +194,16 @@ module.exports = {
     ie: '<=11',
   },
 
+  recaptcha: {
+    siteKey: 'siteKey',
+    disabled: {
+      invite: true,
+      login: false,
+      passwordReset: true,
+      register: true,
+    },
+  },
+
   // No email in tests
   email: undefined,
 

+ 2 - 1
services/web/test/acceptance/src/AuthenticationTests.js

@@ -69,7 +69,7 @@ describe('Authentication', function () {
         operation: 'login',
         ipAddress: '127.0.0.1',
         initiatorId: ObjectId(user.id),
-        info: { method: 'Password login' },
+        info: { method: 'Password login', captcha: 'solved' },
       })
     })
   })
@@ -86,6 +86,7 @@ describe('Authentication', function () {
         json: {
           email: user.email,
           password: 'foo-bar-baz',
+          'g-recaptcha-response': 'valid',
         },
       })
       expect(statusCode).to.equal(401)

+ 127 - 0
services/web/test/acceptance/src/CaptchaTests.js

@@ -0,0 +1,127 @@
+const { expect } = require('chai')
+const User = require('./helpers/User').promises
+
+describe('Captcha', function () {
+  let user
+
+  beforeEach('create user', async function () {
+    user = new User()
+    await user.ensureUserExists()
+  })
+
+  async function loginWithCaptcha(captchaResponse) {
+    return loginWithEmailAndCaptcha(user.email, captchaResponse)
+  }
+
+  async function loginWithEmailAndCaptcha(email, captchaResponse) {
+    await user.getCsrfToken()
+    return user.doRequest('POST', {
+      url: '/login',
+      json: {
+        email,
+        password: user.password,
+        'g-recaptcha-response': captchaResponse,
+      },
+    })
+  }
+
+  async function canSkipCaptcha(email) {
+    await user.getCsrfToken()
+    const { response, body } = await user.doRequest('POST', {
+      url: '/login/can-skip-captcha',
+      json: { email },
+    })
+    expect(response.statusCode).to.equal(200)
+    return body
+  }
+
+  function expectBadCaptchaResponse(response, body) {
+    expect(response.statusCode).to.equal(400)
+    expect(body.errorReason).to.equal('cannot_verify_user_not_robot')
+  }
+
+  function expectSuccessfulLogin(response, body) {
+    expect(response.statusCode).to.equal(200)
+    expect(body).to.deep.equal({ redir: '/project' })
+  }
+
+  it('should reject a login without captcha response', async function () {
+    const { response, body } = await loginWithCaptcha('')
+    expectBadCaptchaResponse(response, body)
+  })
+
+  it('should reject a login with an invalid captcha response', async function () {
+    const { response, body } = await loginWithCaptcha('invalid')
+    expectBadCaptchaResponse(response, body)
+  })
+
+  it('should accept a login with a valid captcha response', async function () {
+    const { response, body } = await loginWithCaptcha('valid')
+    expectSuccessfulLogin(response, body)
+  })
+
+  it('should note the solved captcha in audit log', async function () {
+    const { response, body } = await loginWithCaptcha('valid')
+    expectSuccessfulLogin(response, body)
+
+    expect((await user.get()).auditLog.pop().info).to.deep.equal({
+      captcha: 'solved',
+      method: 'Password login',
+    })
+  })
+
+  describe('deviceHistory', function () {
+    beforeEach('login', async function () {
+      const { response, body } = await loginWithCaptcha('valid')
+      expectSuccessfulLogin(response, body)
+    })
+
+    it('should be able to skip captcha with the same email', async function () {
+      expect(await canSkipCaptcha(user.email)).to.equal(true)
+    })
+
+    it('should be able to omit captcha with the same email', async function () {
+      const { response, body } = await loginWithCaptcha('')
+      expectSuccessfulLogin(response, body)
+    })
+
+    it('should note the skipped captcha in audit log', async function () {
+      const { response, body } = await loginWithCaptcha('')
+      expectSuccessfulLogin(response, body)
+
+      expect((await user.get()).auditLog.pop().info).to.deep.equal({
+        captcha: 'skipped',
+        method: 'Password login',
+      })
+    })
+
+    it('should request a captcha for another email', async function () {
+      expect(await canSkipCaptcha('a@bc.de')).to.equal(false)
+    })
+
+    it('should flag missing captcha for another email', async function () {
+      const { response, body } = await loginWithEmailAndCaptcha('a@bc.de', '')
+      expectBadCaptchaResponse(response, body)
+    })
+
+    describe('cycle history', function () {
+      beforeEach('create and login with 10 other users', async function () {
+        for (let i = 0; i < 10; i++) {
+          const otherUser = new User()
+          otherUser.password = user.password
+          await otherUser.ensureUserExists()
+          const { response, body } = await loginWithEmailAndCaptcha(
+            otherUser.email,
+            'valid'
+          )
+          expectSuccessfulLogin(response, body)
+        }
+      })
+
+      it('should have rolled out the initial users email', async function () {
+        const { response, body } = await loginWithCaptcha('')
+        expectBadCaptchaResponse(response, body)
+      })
+    })
+  })
+})

+ 6 - 0
services/web/test/acceptance/src/HealthCheckControllerTests.js

@@ -5,6 +5,7 @@ const User = require('./helpers/User').promises
 describe('HealthCheckController', function () {
   describe('SmokeTests', function () {
     let user, projectId
+    const captchaDisabledBefore = Settings.recaptcha.disabled.login
 
     beforeEach(async function () {
       user = new User()
@@ -16,6 +17,11 @@ describe('HealthCheckController', function () {
       Settings.smokeTest.user = user.email
       Settings.smokeTest.password = user.password
       Settings.smokeTest.projectId = projectId
+
+      Settings.recaptcha.disabled.login = true
+    })
+    afterEach(function () {
+      Settings.recaptcha.disabled.login = captchaDisabledBefore
     })
 
     async function performSmokeTestRequest() {

+ 1 - 0
services/web/test/acceptance/src/ProjectInviteTests.js

@@ -143,6 +143,7 @@ const tryLoginUser = (user, callback) => {
         json: {
           email: user.email,
           password: user.password,
+          'g-recaptcha-response': 'valid',
         },
       },
       callback

+ 1 - 0
services/web/test/acceptance/src/RegistrationTests.js

@@ -75,6 +75,7 @@ describe('Registration', function () {
             json: {
               email: user.email,
               password: 'invalid-password',
+              'g-recaptcha-response': 'valid',
             },
           })
           const message = body && body.message && body.message.text

+ 2 - 0
services/web/test/acceptance/src/helpers/InitApp.js

@@ -3,11 +3,13 @@ const QueueWorkers = require('../../../../app/src/infrastructure/QueueWorkers')
 const MongoHelper = require('./MongoHelper')
 const RedisHelper = require('./RedisHelper')
 const { logger } = require('@overleaf/logger')
+const MockReCAPTCHAApi = require('../mocks/MockReCaptchaApi')
 
 logger.level('error')
 
 MongoHelper.initialize()
 RedisHelper.initialize()
+MockReCAPTCHAApi.initialize(2222)
 
 let server
 

+ 5 - 1
services/web/test/acceptance/src/helpers/User.js

@@ -120,7 +120,11 @@ class User {
       this.request.post(
         {
           url: settings.enableLegacyLogin ? '/login/legacy' : '/login',
-          json: { email, password: password },
+          json: {
+            email,
+            password,
+            'g-recaptcha-response': 'valid',
+          },
         },
         (error, response, body) => {
           if (error != null) {

+ 4 - 1
services/web/test/acceptance/src/helpers/UserHelper.js

@@ -239,7 +239,10 @@ class UserHelper {
     const loginPath = Settings.enableLegacyLogin ? '/login/legacy' : '/login'
     await userHelper.getCsrfToken()
     const response = await userHelper.request.post(loginPath, {
-      json: userData,
+      json: {
+        'g-recaptcha-response': 'valid',
+        ...userData,
+      },
     })
     if (response.statusCode !== 200 || response.body.redir !== '/project') {
       const error = new Error('login failed')

+ 21 - 0
services/web/test/acceptance/src/mocks/MockReCaptchaApi.js

@@ -0,0 +1,21 @@
+const AbstractMockApi = require('./AbstractMockApi')
+
+class MockReCaptchaApi extends AbstractMockApi {
+  applyRoutes() {
+    this.app.post('/recaptcha/api/siteverify', (req, res) => {
+      res.json({
+        success: req.body.response === 'valid',
+      })
+    })
+  }
+}
+
+module.exports = MockReCaptchaApi
+
+// type hint for the inherited `instance` method
+/**
+ * @function instance
+ * @memberOf MockReCaptchaApi
+ * @static
+ * @returns {MockReCaptchaApi}
+ */