mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
89268bee2c
commit
8e77ada424
21 changed files with 501 additions and 85 deletions
|
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
103
services/web/app/src/Features/Captcha/DeviceHistory.js
Normal file
103
services/web/app/src/Features/Captcha/DeviceHistory.js
Normal 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
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
19
services/web/package-lock.json
generated
19
services/web/package-lock.json
generated
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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
services/web/test/acceptance/src/CaptchaTests.js
Normal file
127
services/web/test/acceptance/src/CaptchaTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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() {
|
||||
|
|
|
@ -143,6 +143,7 @@ const tryLoginUser = (user, callback) => {
|
|||
json: {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
'g-recaptcha-response': 'valid',
|
||||
},
|
||||
},
|
||||
callback
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
services/web/test/acceptance/src/mocks/MockReCaptchaApi.js
Normal file
21
services/web/test/acceptance/src/mocks/MockReCaptchaApi.js
Normal 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}
|
||||
*/
|
Loading…
Reference in a new issue