Merge pull request #6349 from overleaf/jpa-password-strength-checking
[web] data collection for password strength using HaveIBeenPwned api GitOrigin-RevId: 7e4d57a979c29027fb7ca5294f3935500a0b4cf3
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
const { expect } = require('chai')
|
||||
const User = require('./helpers/User').promises
|
||||
const MockHaveIBeenPwnedApiClass = require('./mocks/MockHaveIBeenPwnedApi')
|
||||
const { db } = require('../../../app/src/infrastructure/mongodb')
|
||||
const { getMetric } = require('./helpers/metrics').promises
|
||||
const sleep = require('util').promisify(setTimeout)
|
||||
|
||||
let MockHaveIBeenPwnedApi
|
||||
before(function () {
|
||||
MockHaveIBeenPwnedApi = MockHaveIBeenPwnedApiClass.instance()
|
||||
})
|
||||
|
||||
async function letPasswordCheckRunInBackground() {
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
async function getMetricReUsed() {
|
||||
return getMetric(
|
||||
line => line.includes('password_re_use') && line.includes('re-used')
|
||||
)
|
||||
}
|
||||
|
||||
async function getMetricUnique() {
|
||||
return getMetric(
|
||||
line => line.includes('password_re_use') && line.includes('unique')
|
||||
)
|
||||
}
|
||||
|
||||
async function getMetricFailure() {
|
||||
return getMetric(
|
||||
line => line.includes('password_re_use') && line.includes('failure')
|
||||
)
|
||||
}
|
||||
|
||||
let user, previous
|
||||
async function resetPassword(password) {
|
||||
await user.getCsrfToken()
|
||||
await user.doRequest('POST', {
|
||||
url: '/user/password/reset',
|
||||
form: {
|
||||
email: user.email,
|
||||
},
|
||||
})
|
||||
const token = (
|
||||
await db.tokens.findOne({
|
||||
'data.user_id': user._id.toString(),
|
||||
})
|
||||
).token
|
||||
|
||||
await user.doRequest('GET', {
|
||||
url: `/user/password/set?passwordResetToken=${token}&email=${user.email}`,
|
||||
})
|
||||
await user.doRequest('POST', {
|
||||
url: '/user/password/set',
|
||||
form: {
|
||||
passwordResetToken: token,
|
||||
password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('HaveIBeenPwnedApi', function () {
|
||||
describe('login with weak password', function () {
|
||||
beforeEach(function () {
|
||||
user = new User()
|
||||
user.password = 'aLeakedPassword42'
|
||||
|
||||
// echo -n aLeakedPassword42 | sha1sum
|
||||
MockHaveIBeenPwnedApi.addPasswordByHash(
|
||||
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
|
||||
)
|
||||
})
|
||||
beforeEach('create the user', async function () {
|
||||
await user.ensureUserExists()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
beforeEach('fetch previous count', async function () {
|
||||
previous = await getMetricReUsed()
|
||||
})
|
||||
beforeEach('login', async function () {
|
||||
await user.loginNoUpdate()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
it('should track the weak password', async function () {
|
||||
const after = await getMetricReUsed()
|
||||
expect(after).to.equal(previous + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login with strong password', function () {
|
||||
beforeEach(function () {
|
||||
user = new User()
|
||||
user.password = 'this-is-a-strong-password'
|
||||
})
|
||||
beforeEach('create the user', async function () {
|
||||
await user.ensureUserExists()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
beforeEach('fetch previous count', async function () {
|
||||
previous = await getMetricUnique()
|
||||
})
|
||||
beforeEach('login', async function () {
|
||||
await user.loginNoUpdate()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
it('should track the strong password', async function () {
|
||||
const after = await getMetricUnique()
|
||||
expect(after).to.equal(previous + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the api is producing garbage', function () {
|
||||
beforeEach(function () {
|
||||
user = new User()
|
||||
user.password = 'trigger-garbage-output'
|
||||
})
|
||||
beforeEach('create the user', async function () {
|
||||
await user.ensureUserExists()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
beforeEach('fetch previous count', async function () {
|
||||
previous = await getMetricFailure()
|
||||
})
|
||||
beforeEach('login', async function () {
|
||||
await user.loginNoUpdate()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
it('should track the failure to collect a score', async function () {
|
||||
const after = await getMetricFailure()
|
||||
expect(after).to.equal(previous + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login attempt with weak password', function () {
|
||||
beforeEach(function () {
|
||||
user = new User()
|
||||
// echo -n aLeakedPassword42 | sha1sum
|
||||
MockHaveIBeenPwnedApi.addPasswordByHash(
|
||||
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
|
||||
)
|
||||
})
|
||||
beforeEach('create the user', async function () {
|
||||
await user.ensureUserExists()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
beforeEach('fetch previous counts', async function () {
|
||||
previous = {
|
||||
reUsed: await getMetricReUsed(),
|
||||
unique: await getMetricUnique(),
|
||||
failure: await getMetricFailure(),
|
||||
}
|
||||
})
|
||||
beforeEach('login', async function () {
|
||||
await user.loginWithEmailPassword(user.email, 'aLeakedPassword42')
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
it('should not increment any counter', async function () {
|
||||
expect(previous).to.deep.equal({
|
||||
reUsed: await getMetricReUsed(),
|
||||
unique: await getMetricUnique(),
|
||||
failure: await getMetricFailure(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reset with a weak password', function () {
|
||||
beforeEach(function () {
|
||||
user = new User()
|
||||
// echo -n aLeakedPassword42 | sha1sum
|
||||
MockHaveIBeenPwnedApi.addPasswordByHash(
|
||||
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
|
||||
)
|
||||
})
|
||||
beforeEach('create the user', async function () {
|
||||
await user.ensureUserExists()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
beforeEach('fetch previous count', async function () {
|
||||
previous = await getMetricReUsed()
|
||||
})
|
||||
beforeEach('set password', async function () {
|
||||
await resetPassword('aLeakedPassword42')
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
it('should track the weak password', async function () {
|
||||
const after = await getMetricReUsed()
|
||||
expect(after).to.equal(previous + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reset with a strong password', function () {
|
||||
beforeEach(function () {
|
||||
user = new User()
|
||||
})
|
||||
beforeEach('create the user', async function () {
|
||||
await user.ensureUserExists()
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
beforeEach('fetch previous count', async function () {
|
||||
previous = await getMetricUnique()
|
||||
})
|
||||
beforeEach('set password', async function () {
|
||||
await resetPassword('a-strong-new-password')
|
||||
await letPasswordCheckRunInBackground()
|
||||
})
|
||||
it('should track the strong password', async function () {
|
||||
const after = await getMetricUnique()
|
||||
expect(after).to.equal(previous + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ const MockProjectHistoryApi = require('./mocks/MockProjectHistoryApi')
|
||||
const MockSpellingApi = require('./mocks/MockSpellingApi')
|
||||
const MockV1Api = require('./mocks/MockV1Api')
|
||||
const MockV1HistoryApi = require('./mocks/MockV1HistoryApi')
|
||||
const MockHaveIBeenPwnedApi = require('./mocks/MockHaveIBeenPwnedApi')
|
||||
|
||||
const mockOpts = {
|
||||
debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS),
|
||||
@@ -24,6 +25,7 @@ MockDocUpdaterApi.initialize(3003, mockOpts)
|
||||
MockFilestoreApi.initialize(3009, mockOpts)
|
||||
MockNotificationsApi.initialize(3042, mockOpts)
|
||||
MockSpellingApi.initialize(3005, mockOpts)
|
||||
MockHaveIBeenPwnedApi.initialize(1337, mockOpts)
|
||||
|
||||
if (Features.hasFeature('saas')) {
|
||||
MockAnalyticsApi.initialize(3050, mockOpts)
|
||||
|
||||
@@ -104,29 +104,37 @@ class User {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
this.getCsrfToken(error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
this.request.post(
|
||||
{
|
||||
url: settings.enableLegacyLogin ? '/login/legacy' : '/login',
|
||||
json: { email, password: this.password },
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
// get new csrf token, then return result of login
|
||||
this.getCsrfToken(err => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
callback(null, response, body)
|
||||
})
|
||||
this.loginWithEmailPassword(email, this.password, callback)
|
||||
})
|
||||
}
|
||||
|
||||
loginNoUpdate(callback) {
|
||||
this.loginWithEmailPassword(this.email, this.password, callback)
|
||||
}
|
||||
|
||||
loginWithEmailPassword(email, password, callback) {
|
||||
this.getCsrfToken(error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
this.request.post(
|
||||
{
|
||||
url: settings.enableLegacyLogin ? '/login/legacy' : '/login',
|
||||
json: { email, password: password },
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
// get new csrf token, then return result of login
|
||||
this.getCsrfToken(err => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
callback(null, response, body)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const AbstractMockApi = require('./AbstractMockApi')
|
||||
const {
|
||||
plainTextResponse,
|
||||
} = require('../../../../app/src/infrastructure/Response')
|
||||
|
||||
class MockHaveIBeenPwnedApi extends AbstractMockApi {
|
||||
reset() {
|
||||
this.seenPasswords = {}
|
||||
}
|
||||
|
||||
addPasswordByHash(hash) {
|
||||
this.seenPasswords[hash] |= 0
|
||||
this.seenPasswords[hash]++
|
||||
}
|
||||
|
||||
getPasswordsByRange(prefix) {
|
||||
if (prefix.length !== 5) {
|
||||
throw new Error('prefix must be of length 5')
|
||||
}
|
||||
const matches = [
|
||||
// padding
|
||||
'274CCEF6AB4DFAAF86599792FA9C3FE4689:42',
|
||||
'29780E39FF6511C0FC227744B2817D122F4:1337',
|
||||
]
|
||||
for (const [hash, score] of Object.entries(this.seenPasswords)) {
|
||||
if (hash.startsWith(prefix)) {
|
||||
matches.push(hash.slice(5) + ':' + score)
|
||||
}
|
||||
}
|
||||
return matches.join('\r\n')
|
||||
}
|
||||
|
||||
applyRoutes() {
|
||||
this.app.get('/range/:prefix', (req, res) => {
|
||||
const { prefix } = req.params
|
||||
if (prefix === 'C8893') {
|
||||
plainTextResponse(res, '74D74EFD7B158D2ADD283D67FF3E53B55D7:broken')
|
||||
} else {
|
||||
plainTextResponse(res, this.getPasswordsByRange(prefix))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MockHaveIBeenPwnedApi
|
||||
|
||||
// type hint for the inherited `instance` method
|
||||
/**
|
||||
* @function instance
|
||||
* @memberOf MockHaveIBeenPwnedApi
|
||||
* @static
|
||||
* @returns {MockHaveIBeenPwnedApi}
|
||||
*/
|
||||
Reference in New Issue
Block a user