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

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

GitOrigin-RevId: b091564bfd93f7e587d396c860fd864f220f4b63
This commit is contained in:
Jakob Ackermann 2022-01-26 11:15:19 +00:00 committed by Copybot
parent 89268bee2c
commit 8e77ada424
21 changed files with 501 additions and 85 deletions

View file

@ -530,7 +530,17 @@ function _afterLoginSessionSetup(req, user, callback) {
return callback(err) return callback(err)
} }
UserSessionsManager.trackSession(user, req.sessionID, function () {}) 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())
}) })
}) })
}) })

View file

@ -1,63 +1,100 @@
/* eslint-disable const request = require('request-promise-native')
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 logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const Settings = require('@overleaf/settings') 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 = { function respondInvalidCaptcha(res) {
validateCaptcha(action) { res.status(400).json({
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', errorReason: 'cannot_verify_user_not_robot',
message: { 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.', 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 { }
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() 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),
} }

View file

@ -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

View file

@ -52,6 +52,7 @@ const SystemMessageController = require('./Features/SystemMessages/SystemMessage
const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware') const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware')
const AnalyticsUTMTrackingMiddleware = require('./Features/Analytics/AnalyticsUTMTrackingMiddleware') const AnalyticsUTMTrackingMiddleware = require('./Features/Analytics/AnalyticsUTMTrackingMiddleware')
const SplitTestMiddleware = require('./Features/SplitTests/SplitTestMiddleware') const SplitTestMiddleware = require('./Features/SplitTests/SplitTestMiddleware')
const CaptchaMiddleware = require('./Features/Captcha/CaptchaMiddleware')
const { Joi, validate } = require('./infrastructure/Validation') const { Joi, validate } = require('./infrastructure/Validation')
const { const {
renderUnsupportedBrowserPage, 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) webRouter.get('/login', UserPagesController.loginPage)
AuthenticationController.addEndpointToLoginWhitelist('/login') AuthenticationController.addEndpointToLoginWhitelist('/login')
webRouter.post('/login', AuthenticationController.passportLogin) webRouter.post(
'/login',
CaptchaMiddleware.validateCaptcha('login'),
AuthenticationController.passportLogin
)
if (Settings.enableLegacyLogin) { if (Settings.enableLegacyLogin) {
AuthenticationController.addEndpointToLoginWhitelist('/login/legacy') AuthenticationController.addEndpointToLoginWhitelist('/login/legacy')

View file

@ -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 // Email support
// ------------- // -------------
// //
@ -595,6 +604,9 @@ module.exports = {
// header_extras: [{text: "Some Page", url: "http://example.com/some/page", class: "subdued"}] // header_extras: [{text: "Some Page", url: "http://example.com/some/page", class: "subdued"}]
recaptcha: { recaptcha: {
endpoint:
process.env.RECAPTCHA_ENDPOINT ||
'https://www.google.com/recaptcha/api/siteverify',
disabled: { disabled: {
invite: true, invite: true,
login: true, login: true,

View file

@ -13,6 +13,7 @@ PUBLIC_URL=http://www.overleaf.test:3000
HTTP_TEST_HOST=www.overleaf.test HTTP_TEST_HOST=www.overleaf.test
OT_JWT_AUTH_KEY=very secret key OT_JWT_AUTH_KEY=very secret key
EXTERNAL_AUTH=none EXTERNAL_AUTH=none
RECAPTCHA_ENDPOINT=http://localhost:2222/recaptcha/api/siteverify
# Server-Pro LDAP # Server-Pro LDAP
SHARELATEX_LDAP_URL=ldap://ldap:389 SHARELATEX_LDAP_URL=ldap://ldap:389
SHARELATEX_LDAP_SEARCH_BASE=ou=people,dc=planetexpress,dc=com 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 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 # 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== 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

View file

@ -1,8 +1,31 @@
import { postJSON } from '../../infrastructure/fetch-json'
const grecaptcha = window.grecaptcha const grecaptcha = window.grecaptcha
let recaptchaId let recaptchaId
const recaptchaCallbacks = [] 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() { export async function validateCaptchaV2() {
if ( if (
// Detect blocked recaptcha // Detect blocked recaptcha

View file

@ -1,6 +1,6 @@
import classNames from 'classnames' import classNames from 'classnames'
import { FetchError, postJSON } from '../../infrastructure/fetch-json' import { FetchError, postJSON } from '../../infrastructure/fetch-json'
import { validateCaptchaV2 } from './captcha' import { canSkipCaptcha, validateCaptchaV2 } from './captcha'
import inputValidator from './input-validator' import inputValidator from './input-validator'
import { disableElement, enableElement } from '../utils/disableElement' import { disableElement, enableElement } from '../utils/disableElement'
@ -21,10 +21,23 @@ function formSubmitHelper(formEl) {
const messageBag = [] const messageBag = []
try {
let data
try { try {
const captchaResponse = await validateCaptcha(formEl) const captchaResponse = await validateCaptcha(formEl)
data = await sendFormRequest(formEl, captchaResponse)
const 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')) formEl.dispatchEvent(new Event('sent'))
// Handle redirects // Handle redirects
@ -65,6 +78,17 @@ function formSubmitHelper(formEl) {
async function validateCaptcha(formEl) { async function validateCaptcha(formEl) {
let captchaResponse let captchaResponse
if (formEl.hasAttribute('captcha')) { 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() captchaResponse = await validateCaptchaV2()
} }
return captchaResponse return captchaResponse

View file

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

View file

@ -21411,7 +21411,7 @@
"find-up": { "find-up": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", "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, "dev": true,
"requires": { "requires": {
"locate-path": "^2.0.0" "locate-path": "^2.0.0"
@ -25127,6 +25127,11 @@
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz",
"integrity": "sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w==" "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": { "jquery": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz",
@ -26506,7 +26511,7 @@
"levn": { "levn": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
"integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
"dev": true, "dev": true,
"requires": { "requires": {
"prelude-ls": "~1.1.2", "prelude-ls": "~1.1.2",
@ -26565,7 +26570,7 @@
"locate-path": { "locate-path": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
"integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
"dev": true, "dev": true,
"requires": { "requires": {
"p-locate": "^2.0.0", "p-locate": "^2.0.0",
@ -30109,7 +30114,7 @@
"p-locate": { "p-locate": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
"integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
"dev": true, "dev": true,
"requires": { "requires": {
"p-limit": "^1.1.0" "p-limit": "^1.1.0"
@ -30127,7 +30132,7 @@
"p-try": { "p-try": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", "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 "dev": true
} }
} }
@ -31645,7 +31650,7 @@
"prelude-ls": { "prelude-ls": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
"integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
"dev": true "dev": true
}, },
"prepend-http": { "prepend-http": {
@ -38050,7 +38055,7 @@
"type-check": { "type-check": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
"integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==",
"dev": true, "dev": true,
"requires": { "requires": {
"prelude-ls": "~1.1.2" "prelude-ls": "~1.1.2"

View file

@ -129,6 +129,7 @@
"i18next-fs-backend": "^1.0.7", "i18next-fs-backend": "^1.0.7",
"i18next-http-middleware": "^3.0.2", "i18next-http-middleware": "^3.0.2",
"isomorphic-unfetch": "^3.0.0", "isomorphic-unfetch": "^3.0.0",
"jose": "^4.3.8",
"jquery": "^2.2.4", "jquery": "^2.2.4",
"json2csv": "^4.3.3", "json2csv": "^4.3.3",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",

View file

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

View file

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

View file

@ -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)
})
})
})
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}
*/