From b4156cb3be69b3629f31116a01aa88ae71c9d8a8 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Wed, 26 Jan 2022 11:15:19 +0000 Subject: [PATCH] Merge pull request #6417 from overleaf/jpa-device-history [web] add cookie/JWE based device history for skipping captcha challenge GitOrigin-RevId: b091564bfd93f7e587d396c860fd864f220f4b63 --- .../AuthenticationController.js | 12 +- .../src/Features/Captcha/CaptchaMiddleware.js | 155 +++++++++++------- .../app/src/Features/Captcha/DeviceHistory.js | 103 ++++++++++++ services/web/app/src/router.js | 19 ++- services/web/config/settings.defaults.js | 12 ++ services/web/docker-compose.common.env | 5 + .../js/features/form-helpers/captcha.js | 23 +++ .../js/features/form-helpers/hydrate-form.js | 32 +++- .../frontend/js/infrastructure/fetch-json.js | 21 ++- services/web/package-lock.json | 19 ++- services/web/package.json | 3 +- .../config/settings.test.defaults.js | 10 ++ .../acceptance/src/AuthenticationTests.js | 3 +- .../web/test/acceptance/src/CaptchaTests.js | 127 ++++++++++++++ .../src/HealthCheckControllerTests.js | 6 + .../test/acceptance/src/ProjectInviteTests.js | 1 + .../test/acceptance/src/RegistrationTests.js | 1 + .../test/acceptance/src/helpers/InitApp.js | 2 + .../web/test/acceptance/src/helpers/User.js | 6 +- .../test/acceptance/src/helpers/UserHelper.js | 5 +- .../acceptance/src/mocks/MockReCaptchaApi.js | 21 +++ 21 files changed, 501 insertions(+), 85 deletions(-) create mode 100644 services/web/app/src/Features/Captcha/DeviceHistory.js create mode 100644 services/web/test/acceptance/src/CaptchaTests.js create mode 100644 services/web/test/acceptance/src/mocks/MockReCaptchaApi.js diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index efac5fc7b6..b14da0ee01 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/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()) }) }) }) diff --git a/services/web/app/src/Features/Captcha/CaptchaMiddleware.js b/services/web/app/src/Features/Captcha/CaptchaMiddleware.js index e082b24834..9ac28521a9 100644 --- a/services/web/app/src/Features/Captcha/CaptchaMiddleware.js +++ b/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]) { - return next() - } - const response = req.body['g-recaptcha-response'] - const options = { - form: { - secret: Settings.recaptcha.secretKey, - response, - }, - json: true, - } - 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() - } - } - ) - } - }, +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 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' + ) + } + 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), } diff --git a/services/web/app/src/Features/Captcha/DeviceHistory.js b/services/web/app/src/Features/Captcha/DeviceHistory.js new file mode 100644 index 0000000000..06b90b2559 --- /dev/null +++ b/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 diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 603731750a..8fa13b8ee4 100644 --- a/services/web/app/src/router.js +++ b/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') diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index d009e4aeb8..a7ddecac9b 100644 --- a/services/web/config/settings.defaults.js +++ b/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, diff --git a/services/web/docker-compose.common.env b/services/web/docker-compose.common.env index ab4be336bb..98c52aecff 100644 --- a/services/web/docker-compose.common.env +++ b/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 diff --git a/services/web/frontend/js/features/form-helpers/captcha.js b/services/web/frontend/js/features/form-helpers/captcha.js index 1cce6ead31..fde6765608 100644 --- a/services/web/frontend/js/features/form-helpers/captcha.js +++ b/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 diff --git a/services/web/frontend/js/features/form-helpers/hydrate-form.js b/services/web/frontend/js/features/form-helpers/hydrate-form.js index 3fb8d48058..bc5859926f 100644 --- a/services/web/frontend/js/features/form-helpers/hydrate-form.js +++ b/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 diff --git a/services/web/frontend/js/infrastructure/fetch-json.js b/services/web/frontend/js/infrastructure/fetch-json.js index cb4527cd26..aa1b42de14 100644 --- a/services/web/frontend/js/infrastructure/fetch-json.js +++ b/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) + ) } ) }) diff --git a/services/web/package-lock.json b/services/web/package-lock.json index 759fe46282..333e5666a6 100644 --- a/services/web/package-lock.json +++ b/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" diff --git a/services/web/package.json b/services/web/package.json index 194f2c0576..d285e0545c 100644 --- a/services/web/package.json +++ b/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" } -} \ No newline at end of file +} diff --git a/services/web/test/acceptance/config/settings.test.defaults.js b/services/web/test/acceptance/config/settings.test.defaults.js index 82e7253e9c..a65a02d1ab 100644 --- a/services/web/test/acceptance/config/settings.test.defaults.js +++ b/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, diff --git a/services/web/test/acceptance/src/AuthenticationTests.js b/services/web/test/acceptance/src/AuthenticationTests.js index 1b5ccdf872..1a703c5e72 100644 --- a/services/web/test/acceptance/src/AuthenticationTests.js +++ b/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) diff --git a/services/web/test/acceptance/src/CaptchaTests.js b/services/web/test/acceptance/src/CaptchaTests.js new file mode 100644 index 0000000000..23c5d06b09 --- /dev/null +++ b/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) + }) + }) + }) +}) diff --git a/services/web/test/acceptance/src/HealthCheckControllerTests.js b/services/web/test/acceptance/src/HealthCheckControllerTests.js index 2bcea42956..4275d0068a 100644 --- a/services/web/test/acceptance/src/HealthCheckControllerTests.js +++ b/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() { diff --git a/services/web/test/acceptance/src/ProjectInviteTests.js b/services/web/test/acceptance/src/ProjectInviteTests.js index 9cbfd99b2c..5a7826e026 100644 --- a/services/web/test/acceptance/src/ProjectInviteTests.js +++ b/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 diff --git a/services/web/test/acceptance/src/RegistrationTests.js b/services/web/test/acceptance/src/RegistrationTests.js index 9aaff82f9f..f3c4c16d00 100644 --- a/services/web/test/acceptance/src/RegistrationTests.js +++ b/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 diff --git a/services/web/test/acceptance/src/helpers/InitApp.js b/services/web/test/acceptance/src/helpers/InitApp.js index fad6de67e6..6d26d43513 100644 --- a/services/web/test/acceptance/src/helpers/InitApp.js +++ b/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 diff --git a/services/web/test/acceptance/src/helpers/User.js b/services/web/test/acceptance/src/helpers/User.js index 154d156110..56191b1b00 100644 --- a/services/web/test/acceptance/src/helpers/User.js +++ b/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) { diff --git a/services/web/test/acceptance/src/helpers/UserHelper.js b/services/web/test/acceptance/src/helpers/UserHelper.js index 1b0e632192..c847f0f943 100644 --- a/services/web/test/acceptance/src/helpers/UserHelper.js +++ b/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') diff --git a/services/web/test/acceptance/src/mocks/MockReCaptchaApi.js b/services/web/test/acceptance/src/mocks/MockReCaptchaApi.js new file mode 100644 index 0000000000..ad7a1705ad --- /dev/null +++ b/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} + */