Merge pull request #17408 from overleaf/dp-mongoose-callback-autherntication-manager

Promisify AuthenticationManager and AuthenticationManagerTests

GitOrigin-RevId: 8120bf55d19380a6ecf5241ffab8722eff2d4fe3
This commit is contained in:
David 2024-03-11 10:03:35 +00:00 committed by Copybot
parent 7f23f59df6
commit 1ba5b27e57
2 changed files with 412 additions and 479 deletions

View file

@ -10,7 +10,7 @@ const {
PasswordMustBeDifferentError, PasswordMustBeDifferentError,
PasswordReusedError, PasswordReusedError,
} = require('./AuthenticationErrors') } = require('./AuthenticationErrors')
const util = require('util') const { callbackify } = require('util')
const HaveIBeenPwned = require('./HaveIBeenPwned') const HaveIBeenPwned = require('./HaveIBeenPwned')
const UserAuditLogHandler = require('../User/UserAuditLogHandler') const UserAuditLogHandler = require('../User/UserAuditLogHandler')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
@ -30,13 +30,9 @@ function _exceedsMaximumLengthRatio(password, maxSimilarity, value) {
) )
} }
const _checkWriteResult = function (result, callback) { const _checkWriteResult = function (result) {
// for MongoDB // for MongoDB
if (result && result.modifiedCount === 1) { return !!(result && result.modifiedCount === 1)
callback(null, true)
} else {
callback(null, false)
}
} }
function _validatePasswordNotTooLong(password) { function _validatePasswordNotTooLong(password) {
@ -59,144 +55,129 @@ function _metricsForSuccessfulPasswordMatch(password) {
} }
const AuthenticationManager = { const AuthenticationManager = {
_checkUserPassword(query, password, callback) { async _checkUserPassword(query, password) {
// Using Mongoose for legacy reasons here. The returned User instance // Using Mongoose for legacy reasons here. The returned User instance
// gets serialized into the session and there may be subtle differences // gets serialized into the session and there may be subtle differences
// between the user returned by Mongoose vs mongodb (such as default values) // between the user returned by Mongoose vs mongodb (such as default values)
User.findOne(query, (error, user) => { const user = await User.findOne(query).exec()
if (error) {
return callback(error) if (!user || !user.hashedPassword) {
} return { user: null, match: null }
if (!user || !user.hashedPassword) { }
return callback(null, null, null)
} let rounds = 0
let rounds = 0 try {
try { rounds = bcrypt.getRounds(user.hashedPassword)
rounds = bcrypt.getRounds(user.hashedPassword) } catch (err) {
} catch (err) { let prefix, suffix, length
let prefix, suffix, length if (typeof user.hashedPassword === 'string') {
if (typeof user.hashedPassword === 'string') { length = user.hashedPassword.length
length = user.hashedPassword.length if (user.hashedPassword.length > 50) {
if (user.hashedPassword.length > 50) { // A full bcrypt hash is 60 characters long.
// A full bcrypt hash is 60 characters long. prefix = user.hashedPassword.slice(0, '$2a$12$x'.length)
prefix = user.hashedPassword.slice(0, '$2a$12$x'.length) suffix = user.hashedPassword.slice(-4)
suffix = user.hashedPassword.slice(-4) } else if (user.hashedPassword.length > 20) {
} else if (user.hashedPassword.length > 20) { prefix = user.hashedPassword.slice(0, 4)
prefix = user.hashedPassword.slice(0, 4) suffix = user.hashedPassword.slice(-4)
suffix = user.hashedPassword.slice(-4) } else {
} else { prefix = user.hashedPassword.slice(0, 4)
prefix = user.hashedPassword.slice(0, 4)
}
} }
logger.warn( }
{ logger.warn(
err, {
userId: user._id, err,
hashedPassword: { userId: user._id,
type: typeof user.hashedPassword, hashedPassword: {
length, type: typeof user.hashedPassword,
prefix, length,
suffix, prefix,
}, suffix,
}, },
'unexpected user.hashedPassword value' },
) 'unexpected user.hashedPassword value'
} )
Metrics.inc('bcrypt', 1, { }
method: 'compare', Metrics.inc('bcrypt', 1, {
path: rounds, method: 'compare',
}) path: rounds,
bcrypt.compare(password, user.hashedPassword, function (error, match) {
if (error) {
return callback(error)
}
if (match) {
_metricsForSuccessfulPasswordMatch(password)
}
callback(null, user, match)
})
}) })
const match = await bcrypt.compare(password, user.hashedPassword)
if (match) {
_metricsForSuccessfulPasswordMatch(password)
}
return { user, match }
}, },
authenticate(query, password, auditLog, { skipHIBPCheck = false }, callback) { async authenticate(query, password, auditLog, { skipHIBPCheck = false }) {
AuthenticationManager._checkUserPassword( const { user, match } = await AuthenticationManager._checkUserPassword(
query, query,
password, password
(error, user, match) => {
if (error) {
return callback(error)
}
if (!user) {
return callback(null, null)
}
const update = { $inc: { loginEpoch: 1 } }
if (!match) {
update.$set = { lastFailedLogin: new Date() }
}
User.updateOne(
{ _id: user._id, loginEpoch: user.loginEpoch },
update,
{},
(err, result) => {
if (err) {
return callback(err)
}
if (result.modifiedCount !== 1) {
return callback(new ParallelLoginError())
}
if (!match) {
if (!auditLog) {
return callback(null, null)
} else {
return UserAuditLogHandler.addEntry(
user._id,
'failed-password-match',
user._id,
auditLog.ipAddress,
auditLog.info,
err => {
if (err) {
logger.error(
{ userId: user._id, err, info: auditLog.info },
'Error while adding AuditLog entry for failed-password-match'
)
}
callback(null, null)
}
)
}
}
AuthenticationManager.checkRounds(
user,
user.hashedPassword,
password,
function (err) {
if (err) {
return callback(err)
}
if (skipHIBPCheck) {
callback(null, user)
HaveIBeenPwned.checkPasswordForReuseInBackground(password)
return
}
HaveIBeenPwned.checkPasswordForReuse(
password,
(err, isPasswordReused) => {
if (err) {
logger.err({ err }, 'cannot check password for re-use')
}
if (isPasswordReused) {
return callback(new PasswordReusedError())
}
callback(null, user)
}
)
}
)
}
)
}
) )
if (!user) {
return null
}
const update = { $inc: { loginEpoch: 1 } }
if (!match) {
update.$set = { lastFailedLogin: new Date() }
}
const result = await User.updateOne(
{ _id: user._id, loginEpoch: user.loginEpoch },
update,
{}
).exec()
if (result.modifiedCount !== 1) {
throw new ParallelLoginError()
}
if (!match) {
if (!auditLog) {
return null
} else {
try {
await UserAuditLogHandler.promises.addEntry(
user._id,
'failed-password-match',
user._id,
auditLog.ipAddress,
auditLog.info
)
} catch (err) {
logger.error(
{ userId: user._id, err, info: auditLog.info },
'Error while adding AuditLog entry for failed-password-match'
)
}
return null
}
}
await AuthenticationManager.checkRounds(user, user.hashedPassword, password)
if (skipHIBPCheck) {
HaveIBeenPwned.checkPasswordForReuseInBackground(password)
return user
}
let isPasswordReused
try {
isPasswordReused = await HaveIBeenPwned.promises.checkPasswordForReuse(
password
)
} catch (err) {
logger.err({ err }, 'cannot check password for re-use')
}
if (isPasswordReused) {
throw new PasswordReusedError()
}
return user
}, },
validateEmail(email) { validateEmail(email) {
@ -295,108 +276,93 @@ const AuthenticationManager = {
return null return null
}, },
setUserPassword(user, password, callback) { async setUserPassword(user, password) {
AuthenticationManager.setUserPasswordInV2(user, password, callback) return await AuthenticationManager.setUserPasswordInV2(user, password)
}, },
checkRounds(user, hashedPassword, password, callback) { async checkRounds(user, hashedPassword, password) {
// Temporarily disable this function, TODO: re-enable this // Temporarily disable this function, TODO: re-enable this
if (Settings.security.disableBcryptRoundsUpgrades) { if (Settings.security.disableBcryptRoundsUpgrades) {
Metrics.inc('bcrypt_check_rounds', 1, { status: 'disabled' }) Metrics.inc('bcrypt_check_rounds', 1, { status: 'disabled' })
return callback() return
} }
// check current number of rounds and rehash if necessary // check current number of rounds and rehash if necessary
const currentRounds = bcrypt.getRounds(hashedPassword) const currentRounds = bcrypt.getRounds(hashedPassword)
if (currentRounds < BCRYPT_ROUNDS) { if (currentRounds < BCRYPT_ROUNDS) {
Metrics.inc('bcrypt_check_rounds', 1, { status: 'upgrade' }) Metrics.inc('bcrypt_check_rounds', 1, { status: 'upgrade' })
AuthenticationManager._setUserPasswordInMongo(user, password, callback) return await AuthenticationManager._setUserPasswordInMongo(user, password)
} else { } else {
Metrics.inc('bcrypt_check_rounds', 1, { status: 'success' }) Metrics.inc('bcrypt_check_rounds', 1, { status: 'success' })
callback()
} }
}, },
hashPassword(password, callback) { async hashPassword(password) {
// Double-check the size to avoid truncating in bcrypt. // Double-check the size to avoid truncating in bcrypt.
const error = _validatePasswordNotTooLong(password) const error = _validatePasswordNotTooLong(password)
if (error) { if (error) {
return callback(error) throw error
} }
bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION, function (error, salt) {
if (error) { const salt = await bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION)
return callback(error)
} Metrics.inc('bcrypt', 1, {
Metrics.inc('bcrypt', 1, { method: 'hash',
method: 'hash', path: BCRYPT_ROUNDS,
path: BCRYPT_ROUNDS,
})
bcrypt.hash(password, salt, callback)
}) })
return await bcrypt.hash(password, salt)
}, },
setUserPasswordInV2(user, password, callback) { async setUserPasswordInV2(user, password) {
if (!user || !user.email || !user._id) { if (!user || !user.email || !user._id) {
return callback(new Error('invalid user object')) throw new Error('invalid user object')
} }
const validationError = this.validatePassword(password, user.email) const validationError = this.validatePassword(password, user.email)
if (validationError) { if (validationError) {
return callback(validationError) throw validationError
} }
// check if we can log in with this password. In which case we should reject it, // check if we can log in with this password. In which case we should reject it,
// because it is the same as the existing password. // because it is the same as the existing password.
AuthenticationManager._checkUserPassword( const { match } = await AuthenticationManager._checkUserPassword(
{ _id: user._id }, { _id: user._id },
password, password
(err, _user, match) => {
if (err) {
return callback(err)
}
if (match) {
return callback(new PasswordMustBeDifferentError())
}
HaveIBeenPwned.checkPasswordForReuse(
password,
(error, isPasswordReused) => {
if (error) {
logger.err({ error }, 'cannot check password for re-use')
}
if (!error && isPasswordReused) {
return callback(new PasswordReusedError())
}
// password is strong enough or the validation with the service did not happen
this._setUserPasswordInMongo(user, password, callback)
}
)
}
) )
if (match) {
throw new PasswordMustBeDifferentError()
}
let isPasswordReused
try {
isPasswordReused = await HaveIBeenPwned.promises.checkPasswordForReuse(
password
)
} catch (error) {
logger.err({ error }, 'cannot check password for re-use')
}
if (isPasswordReused) {
throw new PasswordReusedError()
}
// password is strong enough or the validation with the service did not happen
return await this._setUserPasswordInMongo(user, password)
}, },
_setUserPasswordInMongo(user, password, callback) { async _setUserPasswordInMongo(user, password) {
this.hashPassword(password, function (error, hash) { const hash = await this.hashPassword(password)
if (error) { const result = await db.users.updateOne(
return callback(error) { _id: new ObjectId(user._id.toString()) },
} {
db.users.updateOne( $set: {
{ _id: new ObjectId(user._id.toString()) }, hashedPassword: hash,
{
$set: {
hashedPassword: hash,
},
$unset: {
password: true,
},
}, },
function (updateError, result) { $unset: {
if (updateError) { password: true,
return callback(updateError) },
} }
_checkWriteResult(result, callback) )
}
) return _checkWriteResult(result)
})
}, },
_passwordCharactersAreValid(password) { _passwordCharactersAreValid(password) {
@ -500,10 +466,17 @@ const AuthenticationManager = {
}, },
} }
AuthenticationManager.promises = { module.exports = {
authenticate: util.promisify(AuthenticationManager.authenticate), _validatePasswordNotTooSimilar:
hashPassword: util.promisify(AuthenticationManager.hashPassword), AuthenticationManager._validatePasswordNotTooSimilar, // Private function exported for tests
setUserPassword: util.promisify(AuthenticationManager.setUserPassword), validateEmail: AuthenticationManager.validateEmail,
validatePassword: AuthenticationManager.validatePassword,
getMessageForInvalidPasswordError:
AuthenticationManager.getMessageForInvalidPasswordError,
authenticate: callbackify(AuthenticationManager.authenticate),
setUserPassword: callbackify(AuthenticationManager.setUserPassword),
checkRounds: callbackify(AuthenticationManager.checkRounds),
hashPassword: callbackify(AuthenticationManager.hashPassword),
setUserPasswordInV2: callbackify(AuthenticationManager.setUserPasswordInV2),
promises: AuthenticationManager,
} }
module.exports = AuthenticationManager

View file

@ -14,14 +14,18 @@ describe('AuthenticationManager', function () {
this.settings = { security: { bcryptRounds: 4 } } this.settings = { security: { bcryptRounds: 4 } }
this.metrics = { inc: sinon.stub().returns() } this.metrics = { inc: sinon.stub().returns() }
this.HaveIBeenPwned = { this.HaveIBeenPwned = {
checkPasswordForReuse: sinon.stub().yields(null, false), promises: {
checkPasswordForReuse: sinon.stub().resolves(false),
},
checkPasswordForReuseInBackground: sinon.stub(), checkPasswordForReuseInBackground: sinon.stub(),
} }
this.AuthenticationManager = SandboxedModule.require(modulePath, { this.AuthenticationManager = SandboxedModule.require(modulePath, {
requires: { requires: {
'../../models/User': { '../../models/User': {
User: (this.User = { User: (this.User = {
updateOne: sinon.stub().callsArgWith(3, null, { modifiedCount: 1 }), updateOne: sinon
.stub()
.returns({ exec: sinon.stub().resolves({ modifiedCount: 1 }) }),
}), }),
}, },
'../../infrastructure/mongodb': { '../../infrastructure/mongodb': {
@ -32,16 +36,17 @@ describe('AuthenticationManager', function () {
getRounds: sinon.stub().returns(4), getRounds: sinon.stub().returns(4),
}), }),
'@overleaf/settings': this.settings, '@overleaf/settings': this.settings,
'../User/UserGetter': (this.UserGetter = {}), '../User/UserGetter': (this.UserGetter = { promises: {} }),
'./AuthenticationErrors': AuthenticationErrors, './AuthenticationErrors': AuthenticationErrors,
'./HaveIBeenPwned': this.HaveIBeenPwned, './HaveIBeenPwned': this.HaveIBeenPwned,
'../User/UserAuditLogHandler': (this.UserAuditLogHandler = { '../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
addEntry: sinon.stub().callsArgWith(5, null), promises: {
addEntry: sinon.stub().resolves(null),
},
}), }),
'@overleaf/metrics': this.metrics, '@overleaf/metrics': this.metrics,
}, },
}) })
this.callback = sinon.stub()
}) })
afterEach(function () { afterEach(function () {
@ -67,22 +72,20 @@ describe('AuthenticationManager', function () {
email: (this.email = 'USER@overleaf.com'), email: (this.email = 'USER@overleaf.com'),
} }
this.user.hashedPassword = this.testPassword this.user.hashedPassword = this.testPassword
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user) this.User.findOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves(this.user) })
this.metrics.inc.reset() this.metrics.inc.reset()
}) })
describe('when the hashed password matches', function () { describe('when the hashed password matches', function () {
beforeEach(function (done) { beforeEach(async function () {
this.unencryptedPassword = 'testpassword' this.unencryptedPassword = 'testpassword'
this.AuthenticationManager.authenticate( this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencryptedPassword, this.unencryptedPassword,
null, null,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
(error, user) => {
this.callback(error, user)
done()
}
) )
}) })
@ -104,7 +107,7 @@ describe('AuthenticationManager', function () {
}) })
it('should return the user', function () { it('should return the user', function () {
this.callback.should.have.been.calledWith(null, this.user) this.result.should.equal(this.user)
}) })
it('should send metrics', function () { it('should send metrics', function () {
@ -115,16 +118,12 @@ describe('AuthenticationManager', function () {
}) })
describe('when the encrypted passwords do not match', function () { describe('when the encrypted passwords do not match', function () {
beforeEach(function (done) { beforeEach(async function () {
this.AuthenticationManager.authenticate( this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
'notthecorrectpassword', 'notthecorrectpassword',
null, null,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
(...args) => {
this.callback(...args)
done()
}
) )
}) })
@ -142,7 +141,7 @@ describe('AuthenticationManager', function () {
}) })
it('should not return the user', function () { it('should not return the user', function () {
this.callback.calledWith(null, null).should.equal(true) expect(this.result).to.equal(null)
}) })
}) })
@ -150,50 +149,37 @@ describe('AuthenticationManager', function () {
beforeEach(function () { beforeEach(function () {
this.User.updateOne = sinon this.User.updateOne = sinon
.stub() .stub()
.callsArgWith(3, null, { modifiedCount: 0 }) .returns({ exec: sinon.stub().resolves({ modifiedCount: 0 }) })
}) })
describe('correct password', function () { describe('correct password', function () {
beforeEach(function (done) { it('should return an error', async function () {
this.AuthenticationManager.authenticate( await expect(
{ email: this.email }, this.AuthenticationManager.promises.authenticate(
'testpassword', { email: this.email },
null, 'testpassword',
{ skipHIBPCheck: true }, null,
(...args) => { { skipHIBPCheck: true }
this.callback(...args) )
done() ).to.be.rejectedWith(AuthenticationErrors.ParallelLoginError)
}
)
})
it('should return an error', function () {
this.callback.should.have.been.calledWith(
sinon.match.instanceOf(AuthenticationErrors.ParallelLoginError)
)
}) })
}) })
describe('bad password', function () { describe('bad password', function () {
beforeEach(function (done) { beforeEach(function () {
this.User.updateOne = sinon this.User.updateOne = sinon
.stub() .stub()
.yields(null, { modifiedCount: 0 }) .returns({ exec: sinon.stub().resolves({ modifiedCount: 0 }) })
this.AuthenticationManager.authenticate(
{ email: this.email },
'notthecorrectpassword',
null,
{ skipHIBPCheck: true },
(...args) => {
this.callback(...args)
done()
}
)
}) })
it('should return an error', function () { it('should return an error', async function () {
this.callback.should.have.been.calledWith( await expect(
sinon.match.instanceOf(AuthenticationErrors.ParallelLoginError) this.AuthenticationManager.promises.authenticate(
) { email: this.email },
'notthecorrectpassword',
null,
{ skipHIBPCheck: true }
)
).to.be.rejectedWith(AuthenticationErrors.ParallelLoginError)
}) })
}) })
}) })
@ -206,50 +192,41 @@ describe('AuthenticationManager', function () {
email: (this.email = 'USER@overleaf.com'), email: (this.email = 'USER@overleaf.com'),
} }
this.db.users.updateOne = sinon this.db.users.updateOne = sinon
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user) this.User.findOne = sinon
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, false)
this.db.users.updateOne = sinon
.stub() .stub()
.callsArgWith(2, null, { modifiedCount: 1 }) .returns({ exec: sinon.stub().resolves(this.user) })
this.bcrypt.compare = sinon.stub().resolves(false)
this.db.users.updateOne = sinon.stub().resolves({ modifiedCount: 1 })
}) })
it('should not produce an error', function (done) { it('should not produce an error', async function () {
this.AuthenticationManager.setUserPasswordInV2( const updated =
this.user, await this.AuthenticationManager.promises.setUserPasswordInV2(
'testpassword', this.user,
(err, updated) => { 'testpassword'
expect(err).to.not.exist )
expect(updated).to.equal(true) expect(updated).to.equal(true)
done()
}
)
}) })
it('should set the hashed password', function (done) { it('should set the hashed password', async function () {
this.AuthenticationManager.setUserPasswordInV2( await this.AuthenticationManager.promises.setUserPasswordInV2(
this.user, this.user,
'testpassword', 'testpassword'
err => {
expect(err).to.not.exist
const { hashedPassword } =
this.db.users.updateOne.lastCall.args[1].$set
expect(hashedPassword).to.exist
expect(hashedPassword.length).to.equal(60)
expect(hashedPassword).to.match(/^\$2a\$04\$[a-zA-Z0-9/.]{53}$/)
done()
}
) )
const { hashedPassword } = this.db.users.updateOne.lastCall.args[1].$set
expect(hashedPassword).to.exist
expect(hashedPassword.length).to.equal(60)
expect(hashedPassword).to.match(/^\$2a\$04\$[a-zA-Z0-9/.]{53}$/)
}) })
}) })
}) })
describe('hashPassword', function () { describe('hashPassword', function () {
it('should block too long passwords', function (done) { it('should block too long passwords', async function () {
this.AuthenticationManager.hashPassword('x'.repeat(100), err => { await expect(
expect(err).to.exist this.AuthenticationManager.promises.hashPassword('x'.repeat(100))
expect(err.message).to.equal('password is too long') ).to.be.rejectedWith('password is too long')
done()
})
}) })
}) })
@ -261,24 +238,22 @@ describe('AuthenticationManager', function () {
email: (this.email = 'USER@overleaf.com'), email: (this.email = 'USER@overleaf.com'),
} }
this.unencryptedPassword = 'banana' this.unencryptedPassword = 'banana'
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user) this.User.findOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves(this.user) })
this.metrics.inc.reset() this.metrics.inc.reset()
}) })
describe('when the hashed password matches', function () { describe('when the hashed password matches', function () {
beforeEach(function (done) { beforeEach(async function () {
this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, true) this.bcrypt.compare = sinon.stub().resolves(true)
this.bcrypt.getRounds = sinon.stub().returns(4) this.bcrypt.getRounds = sinon.stub().returns(4)
this.AuthenticationManager.authenticate( this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencryptedPassword, this.unencryptedPassword,
null, null,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
(error, user) => {
this.callback(error, user)
done()
}
) )
}) })
@ -301,70 +276,65 @@ describe('AuthenticationManager', function () {
}) })
it('should return the user', function () { it('should return the user', function () {
this.callback.calledWith(null, this.user).should.equal(true) this.result.should.equal(this.user)
}) })
describe('HIBP', function () { describe('HIBP', function () {
it('should not check HIBP if not requested', function () { it('should not check HIBP if not requested', function () {
this.HaveIBeenPwned.checkPasswordForReuse.should.not.have.been this.HaveIBeenPwned.promises.checkPasswordForReuse.should.not.have
.called .been.called
}) })
it('should check HIBP if requested', function (done) { it('should check HIBP if requested', async function () {
this.AuthenticationManager.authenticate( await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencryptedPassword, this.unencryptedPassword,
null, null,
{ skipHIBPCheck: false }, { skipHIBPCheck: false }
error => { )
if (error) return done(error)
this.HaveIBeenPwned.checkPasswordForReuse.should.have.been.calledWith( this.HaveIBeenPwned.promises.checkPasswordForReuse.should.have.been.calledWith(
this.unencryptedPassword this.unencryptedPassword
)
done()
}
) )
}) })
}) })
}) })
describe('when the encrypted passwords do not match', function () { describe('when the encrypted passwords do not match', function () {
beforeEach(function () { beforeEach(async function () {
this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, false) this.bcrypt.compare = sinon.stub().resolves(false)
this.AuthenticationManager.authenticate( this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencryptedPassword, this.unencryptedPassword,
null, null,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
this.callback
) )
}) })
it('should not return the user', function () { it('should not return the user', function () {
this.callback.calledWith(null, null).should.equal(true) expect(this.result).to.equal(null)
this.UserAuditLogHandler.addEntry.callCount.should.equal(0) this.UserAuditLogHandler.promises.addEntry.callCount.should.equal(0)
}) })
}) })
describe('when the encrypted passwords do not match, with auditLog', function () { describe('when the encrypted passwords do not match, with auditLog', function () {
beforeEach(function () { beforeEach(async function () {
this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, false) this.bcrypt.compare = sinon.stub().resolves(false)
this.auditLog = { ipAddress: 'ip', info: { method: 'foo' } } this.auditLog = { ipAddress: 'ip', info: { method: 'foo' } }
this.AuthenticationManager.authenticate( this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencryptedPassword, this.unencryptedPassword,
this.auditLog, this.auditLog,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
this.callback
) )
}) })
it('should not return the user, but add entry to audit log', function () { it('should not return the user, but add entry to audit log', function () {
this.callback.calledWith(null, null).should.equal(true) expect(this.result).to.equal(null)
this.UserAuditLogHandler.addEntry.callCount.should.equal(1) this.UserAuditLogHandler.promises.addEntry.callCount.should.equal(1)
this.UserAuditLogHandler.addEntry this.UserAuditLogHandler.promises.addEntry
.calledWith( .calledWith(
this.user._id, this.user._id,
'failed-password-match', 'failed-password-match',
@ -377,22 +347,18 @@ describe('AuthenticationManager', function () {
}) })
describe('when the hashed password matches but the number of rounds is too low', function () { describe('when the hashed password matches but the number of rounds is too low', function () {
beforeEach(function (done) { beforeEach(async function () {
this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, true) this.bcrypt.compare = sinon.stub().resolves(true)
this.bcrypt.getRounds = sinon.stub().returns(1) this.bcrypt.getRounds = sinon.stub().returns(1)
this.AuthenticationManager._setUserPasswordInMongo = sinon this.AuthenticationManager.promises._setUserPasswordInMongo = sinon
.stub() .stub()
.callsArgWith(2, null) .resolves()
this.AuthenticationManager.authenticate( this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencryptedPassword, this.unencryptedPassword,
null, null,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
(error, user) => {
this.callback(error, user)
done()
}
) )
}) })
@ -415,34 +381,30 @@ describe('AuthenticationManager', function () {
}) })
it('should set the users password (with a higher number of rounds)', function () { it('should set the users password (with a higher number of rounds)', function () {
this.AuthenticationManager._setUserPasswordInMongo this.AuthenticationManager.promises._setUserPasswordInMongo
.calledWith(this.user, this.unencryptedPassword) .calledWith(this.user, this.unencryptedPassword)
.should.equal(true) .should.equal(true)
}) })
it('should return the user', function () { it('should return the user', function () {
this.callback.calledWith(null, this.user).should.equal(true) this.result.should.equal(this.user)
}) })
}) })
describe('when the hashed password matches but the number of rounds is too low, but upgrades disabled', function () { describe('when the hashed password matches but the number of rounds is too low, but upgrades disabled', function () {
beforeEach(function (done) { beforeEach(async function () {
this.settings.security.disableBcryptRoundsUpgrades = true this.settings.security.disableBcryptRoundsUpgrades = true
this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, true) this.bcrypt.compare = sinon.stub().resolves(true)
this.bcrypt.getRounds = sinon.stub().returns(1) this.bcrypt.getRounds = sinon.stub().returns(1)
this.AuthenticationManager.setUserPassword = sinon this.AuthenticationManager.promises.setUserPassword = sinon
.stub() .stub()
.callsArgWith(2, null) .resolves()
this.AuthenticationManager.authenticate( this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencryptedPassword, this.unencryptedPassword,
null, null,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
(error, user) => {
this.callback(error, user)
done()
}
) )
}) })
@ -455,31 +417,32 @@ describe('AuthenticationManager', function () {
}) })
it('should not set the users password (with a higher number of rounds)', function () { it('should not set the users password (with a higher number of rounds)', function () {
this.AuthenticationManager.setUserPassword this.AuthenticationManager.promises.setUserPassword
.calledWith(this.user, this.unencryptedPassword) .calledWith(this.user, this.unencryptedPassword)
.should.equal(false) .should.equal(false)
}) })
it('should return the user', function () { it('should return the user', function () {
this.callback.calledWith(null, this.user).should.equal(true) this.result.should.equal(this.user)
}) })
}) })
}) })
describe('when the user does not exist in the database', function () { describe('when the user does not exist in the database', function () {
beforeEach(function () { beforeEach(async function () {
this.User.findOne = sinon.stub().callsArgWith(1, null, null) this.User.findOne = sinon
this.AuthenticationManager.authenticate( .stub()
.returns({ exec: sinon.stub().resolves(null) })
this.result = await this.AuthenticationManager.promises.authenticate(
{ email: this.email }, { email: this.email },
this.unencrpytedPassword, this.unencrpytedPassword,
null, null,
{ skipHIBPCheck: true }, { skipHIBPCheck: true }
this.callback
) )
}) })
it('should not return a user', function () { it('should not return a user', function () {
this.callback.calledWith(null, null).should.equal(true) expect(this.result).to.equal(null)
}) })
}) })
}) })
@ -799,28 +762,27 @@ describe('AuthenticationManager', function () {
email: 'user@example.com', email: 'user@example.com',
hashedPassword: this.hashedPassword, hashedPassword: this.hashedPassword,
} }
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, false) this.bcrypt.compare = sinon.stub().resolves(false)
this.bcrypt.genSalt = sinon.stub().callsArgWith(2, null, this.salt) this.bcrypt.genSalt = sinon.stub().resolves(this.salt)
this.bcrypt.hash = sinon.stub().callsArgWith(2, null, this.hashedPassword) this.bcrypt.hash = sinon.stub().resolves(this.hashedPassword)
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user) this.User.findOne = sinon
this.db.users.updateOne = sinon.stub().callsArg(2) .stub()
.returns({ exec: sinon.stub().resolves(this.user) })
this.db.users.updateOne = sinon.stub().resolves()
}) })
describe('same as previous password', function () { describe('same as previous password', function () {
beforeEach(function () { beforeEach(function () {
this.bcrypt.compare.callsArgWith(2, null, true) this.bcrypt.compare.resolves(true)
}) })
it('should return an error', function (done) { it('should be rejected', async function () {
this.AuthenticationManager.setUserPassword( await expect(
this.user, this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
err => { this.password
expect(err).to.exist )
expect(err.name).to.equal('PasswordMustBeDifferentError') ).to.be.rejectedWith(AuthenticationErrors.PasswordMustBeDifferentError)
done()
}
)
}) })
}) })
@ -834,27 +796,25 @@ describe('AuthenticationManager', function () {
this.password = 'dsdsadsadsadsadsadkjsadjsadjsadljs' this.password = 'dsdsadsadsadsadsadkjsadjsadjsadljs'
}) })
it('should return and error', function (done) { it('should return and error', async function () {
this.AuthenticationManager.setUserPassword( await expect(
this.user, this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
err => { this.password
expect(err).to.exist )
done() ).to.be.rejectedWith('password is too long')
}
)
}) })
it('should not start the bcrypt process', function (done) { it('should not start the bcrypt process', async function () {
this.AuthenticationManager.setUserPassword( await expect(
this.user, this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
() => { this.password
this.bcrypt.genSalt.called.should.equal(false) )
this.bcrypt.hash.called.should.equal(false) ).to.be.rejected
done()
} this.bcrypt.genSalt.called.should.equal(false)
) this.bcrypt.hash.called.should.equal(false)
}) })
}) })
@ -863,16 +823,13 @@ describe('AuthenticationManager', function () {
this.password = `some${this.user.email}password` this.password = `some${this.user.email}password`
}) })
it('should reject the password', function (done) { it('should reject the password', async function () {
this.AuthenticationManager.setUserPassword( await expect(
this.user, this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
err => { this.password
expect(err).to.exist )
expect(err.name).to.equal('InvalidPasswordError') ).to.be.rejectedWith(AuthenticationErrors.InvalidPasswordError)
done()
}
)
}) })
}) })
@ -881,16 +838,13 @@ describe('AuthenticationManager', function () {
this.password = `some${this.user.email.split('@')[0]}password` this.password = `some${this.user.email.split('@')[0]}password`
}) })
it('should reject the password', function (done) { it('should reject the password', async function () {
this.AuthenticationManager.setUserPassword( await expect(
this.user, this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
err => { this.password
expect(err).to.exist )
expect(err.name).to.equal('InvalidPasswordError') ).to.be.rejectedWith(AuthenticationErrors.InvalidPasswordError)
done()
}
)
}) })
}) })
@ -901,13 +855,17 @@ describe('AuthenticationManager', function () {
user = { _id: 'some-user-id', email: 'someuser@somedomain.com' } user = { _id: 'some-user-id', email: 'someuser@somedomain.com' }
}) })
it('should reject the password', function (done) { it('should reject the password', async function () {
this.AuthenticationManager.setUserPassword(user, password, err => { try {
expect(err).to.exist await this.AuthenticationManager.promises.setUserPassword(
user,
password
)
expect.fail('should have thrown')
} catch (err) {
expect(err.name).to.equal('InvalidPasswordError') expect(err.name).to.equal('InvalidPasswordError')
expect(err?.info?.code).to.equal('contains_email') expect(err?.info?.code).to.equal('contains_email')
done() }
})
}) })
}) })
@ -922,27 +880,25 @@ describe('AuthenticationManager', function () {
this.password = 'dsd' this.password = 'dsd'
}) })
it('should return and error', function (done) { it('should return and error', async function () {
this.AuthenticationManager.setUserPassword( await expect(
this.user, this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
err => { this.password
expect(err).to.exist )
done() ).to.be.rejectedWith('password is too short')
}
)
}) })
it('should not start the bcrypt process', function (done) { it('should not start the bcrypt process', async function () {
this.AuthenticationManager.setUserPassword( await expect(
this.user, this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
() => { this.password
this.bcrypt.genSalt.called.should.equal(false) )
this.bcrypt.hash.called.should.equal(false) ).to.be.rejected
done()
} this.bcrypt.genSalt.called.should.equal(false)
) this.bcrypt.hash.called.should.equal(false)
}) })
}) })
@ -953,45 +909,54 @@ describe('AuthenticationManager', function () {
this.metrics.inc.reset() this.metrics.inc.reset()
}) })
it('should produce an error when the password is too similar to the email', function (done) { it('should produce an error when the password is too similar to the email', async function () {
this.AuthenticationManager.setUserPassword( try {
this.user, await this.AuthenticationManager.promises.setUserPassword(
this.password, this.user,
err => { this.password
expect(err).to.exist )
expect(err?.info?.code).to.equal('too_similar') expect.fail('should have thrown')
expect( } catch (err) {
this.metrics.inc.calledWith('password-too-similar-to-email') expect(err.message).to.equal(
).to.equal(true) 'password is too similar to email address'
done() )
} expect(err?.info?.code).to.equal('too_similar')
) }
expect(
this.metrics.inc.calledWith('password-too-similar-to-email')
).to.equal(true)
}) })
it('should produce an error when the password is too similar to the email, regardless of case', function (done) { it('should produce an error when the password is too similar to the email, regardless of case', async function () {
this.AuthenticationManager.setUserPassword( try {
this.user, await this.AuthenticationManager.promises.setUserPassword(
this.password.toUpperCase(), this.user,
err => { this.password.toUpperCase()
expect(err).to.exist )
expect(err?.info?.code).to.equal('too_similar') expect.fail('should have thrown')
expect( } catch (err) {
this.metrics.inc.calledWith('password-too-similar-to-email') expect(err.message).to.equal(
).to.equal(true) 'password is too similar to email address'
done() )
} expect(err?.info?.code).to.equal('too_similar')
) }
expect(
this.metrics.inc.calledWith('password-too-similar-to-email')
).to.equal(true)
}) })
}) })
describe('successful password set attempt', function () { describe('successful password set attempt', function () {
beforeEach(function () { beforeEach(async function () {
this.metrics.inc.reset() this.metrics.inc.reset()
this.UserGetter.getUser = sinon.stub().yields(null, { overleaf: null }) this.UserGetter.promises.getUser = sinon
this.AuthenticationManager.setUserPassword( .stub()
.resolves({ overleaf: null })
await this.AuthenticationManager.promises.setUserPassword(
this.user, this.user,
this.password, this.password
this.callback
) )
}) })
@ -1020,11 +985,6 @@ describe('AuthenticationManager', function () {
this.metrics.inc.calledWith('password-too-similar-to-email') this.metrics.inc.calledWith('password-too-similar-to-email')
).to.equal(false) ).to.equal(false)
}) })
it('should call the callback', function () {
this.callback.called.should.equal(true)
expect(this.callback.lastCall.args[0]).to.not.be.instanceOf(Error)
})
}) })
}) })
}) })