Files
overleaf-cep/services/web/test/acceptance/src/CaptchaTests.mjs
Andrew Rumble f0827f0e67 consider trustedUsersRegex when choosing to show captcha at login
GitOrigin-RevId: 963fe1c40d05fe088a092eb45b12bcddf1f18e7b
2025-07-09 08:05:56 +00:00

315 lines
10 KiB
JavaScript

import { db } from '../../../app/src/infrastructure/mongodb.js'
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import UserHelper from './helpers/User.mjs'
import MockHaveIBeenPwnedApiClass from './mocks/MockHaveIBeenPwnedApi.mjs'
const User = UserHelper.promises
let MockHaveIBeenPwnedApi
before(function () {
MockHaveIBeenPwnedApi = MockHaveIBeenPwnedApiClass.instance()
})
describe('Captcha', function () {
let user
beforeEach('create user', async function () {
user = new User()
await user.ensureUserExists()
})
async function login(email, password, captchaResponse) {
await user.getCsrfToken()
return user.doRequest('POST', {
url: '/login',
json: {
email,
password,
'g-recaptcha-response': captchaResponse,
},
})
}
async function loginWithCaptcha(captchaResponse) {
return login(user.email, user.password, captchaResponse)
}
async function loginWithEmailAndCaptcha(email, captchaResponse) {
return login(email, user.password, 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' })
}
function expectSuccessfulLoginWithRedirectToCompromisedPasswordPage(
response,
body
) {
expect(response.statusCode).to.equal(200)
expect(body).to.deep.equal({ redir: '/compromised-password' })
}
function expectBadLogin(response, body) {
expect(response.statusCode).to.equal(401)
expect(body).to.deep.equal({
message: {
type: 'error',
key: 'invalid-password-retry-or-reset',
},
})
}
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)
const auditLog = await user.getAuditLog()
expect(auditLog[0].info).to.deep.equal({
captcha: 'solved',
method: 'Password login',
fromKnownDevice: false,
})
})
describe('trustedUsersRegex', function () {
let resetTrustedUsersRegex
beforeEach(function () {
resetTrustedUsersRegex = Settings.recaptcha.trustedUsersRegex
Settings.recaptcha.trustedUsersRegex = /\+trusted@example\.com$/
})
afterEach(function () {
Settings.recaptcha.trustedUsersRegex = resetTrustedUsersRegex
})
describe('when user is trusted, can login without captcha', function () {
let trustedUser
beforeEach(async function () {
trustedUser = new User({
email: 'acceptance-test+trusted@example.com',
})
await trustedUser.ensureUserExists()
})
it('should be able to skip captcha', async function () {
expect(await canSkipCaptcha(trustedUser.email)).to.equal(true)
})
it('should note that the user is trusted', async function () {
const { response, body } = await login(
trustedUser.email,
trustedUser.password,
''
)
expectSuccessfulLogin(response, body)
const auditLog = await trustedUser.getAuditLog()
expect(auditLog[0].info).to.deep.equal({
captcha: 'trusted',
method: 'Password login',
})
})
})
describe('when user is not trusted', function () {
it('should not be able to skip captcha', async function () {
expect(await canSkipCaptcha(user.email)).to.equal(false)
})
it('should not add an audit log entry for trusted user', async function () {
const { response, body } = await login(user.email, user.password, '')
expectBadCaptchaResponse(response, body)
const auditLog = await user.getAuditLog()
expect(auditLog).to.have.lengthOf(0)
})
})
})
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)
const auditLog = await user.getAuditLog()
expect(auditLog[1].info).to.deep.equal({
captcha: 'skipped',
method: 'Password login',
fromKnownDevice: true,
})
})
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('login failure', function () {
beforeEach(async function () {
const { response, body } = await login(
user.email,
'bad password',
'valid'
)
expectBadLogin(response, body)
})
it('should be able to skip captcha per device history', async function () {
expect(await canSkipCaptcha(user.email)).to.equal(true)
})
it('should request a captcha despite device history entry', async function () {
const { response, body } = await loginWithCaptcha('')
expectBadCaptchaResponse(response, body)
})
it('should accept the login with captcha', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
})
describe('when the login failure happened a long time ago', function () {
beforeEach(async function () {
db.users.updateOne(
{ email: user.email },
{
$set: {
lastFailedLogin: new Date(
Date.now() - 90 * 24 * 60 * 60 * 1000
),
},
}
)
})
it('should be able to skip captcha per device history', async function () {
expect(await canSkipCaptcha(user.email)).to.equal(true)
})
it('should accept the login without captcha', async function () {
const { response, body } = await loginWithCaptcha('')
expectSuccessfulLogin(response, body)
})
it('should accept the login with captcha', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(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)
})
})
describe('HIBP', function () {
before(function () {
Settings.apis.haveIBeenPwned.enabled = true
})
after(function () {
Settings.apis.haveIBeenPwned.enabled = false
})
beforeEach(async function () {
user = new User()
user.password = 'aLeakedPassword42'
await user.ensureUserExists()
})
beforeEach('login to populate deviceHistory', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
})
beforeEach(function () {
// echo -n aLeakedPassword42 | sha1sum
MockHaveIBeenPwnedApi.addPasswordByHash(
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
)
})
it('should be able to skip HIBP check with deviceHistory and valid captcha', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLoginWithRedirectToCompromisedPasswordPage(
response,
body
)
})
it('should be able to skip HIBP check with deviceHistory and skipped captcha', async function () {
const { response, body } = await loginWithCaptcha('')
expectSuccessfulLoginWithRedirectToCompromisedPasswordPage(
response,
body
)
})
it('should not be able to skip HIBP check without deviceHistory', async function () {
user.resetCookies()
const { response, body } = await loginWithCaptcha('valid')
expect(response.statusCode).to.equal(400)
expect(body.message.key).to.equal('password-compromised')
})
})
})
})