mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 12:53:39 -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)
|
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())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
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 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')
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
19
services/web/package-lock.json
generated
19
services/web/package-lock.json
generated
|
@ -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"
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
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('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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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')
|
||||||
|
|
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