Merge pull request #3234 from overleaf/sk-fix-password-validation-email

Overhaul password validation

GitOrigin-RevId: a591c4e192e30a0ac053eab6f80627543a8a92fe
This commit is contained in:
Shane Kilkelly 2020-10-22 09:11:43 +01:00 committed by Copybot
parent 85d37f8990
commit e9f7a17093
13 changed files with 127 additions and 91 deletions

View file

@ -3,7 +3,6 @@ const { User } = require('../../models/User')
const { db, ObjectId } = require('../../infrastructure/mongodb') const { db, ObjectId } = require('../../infrastructure/mongodb')
const bcrypt = require('bcrypt') const bcrypt = require('bcrypt')
const EmailHelper = require('../Helpers/EmailHelper') const EmailHelper = require('../Helpers/EmailHelper')
const V1Handler = require('../V1/V1Handler')
const { const {
InvalidEmailError, InvalidEmailError,
InvalidPasswordError InvalidPasswordError
@ -66,8 +65,8 @@ const AuthenticationManager = {
// validates a password based on a similar set of rules to `complexPassword.js` on the frontend // validates a password based on a similar set of rules to `complexPassword.js` on the frontend
// note that `passfield.js` enforces more rules than this, but these are the most commonly set. // note that `passfield.js` enforces more rules than this, but these are the most commonly set.
// returns null on success, or an error string. // returns null on success, or an error object.
validatePassword(password) { validatePassword(password, email) {
if (password == null) { if (password == null) {
return new InvalidPasswordError({ return new InvalidPasswordError({
message: 'password not set', message: 'password not set',
@ -113,11 +112,23 @@ const AuthenticationManager = {
info: { code: 'invalid_character' } info: { code: 'invalid_character' }
}) })
} }
if (typeof email === 'string' && email !== '') {
const startOfEmail = email.split('@')[0]
if (
password.indexOf(email) !== -1 ||
password.indexOf(startOfEmail) !== -1
) {
return new InvalidPasswordError({
message: 'password contains part of email address',
info: { code: 'contains_email' }
})
}
}
return null return null
}, },
setUserPassword(userId, password, callback) { setUserPassword(user, password, callback) {
AuthenticationManager.setUserPasswordInV2(userId, password, callback) AuthenticationManager.setUserPasswordInV2(user, password, callback)
}, },
checkRounds(user, hashedPassword, password, callback) { checkRounds(user, hashedPassword, password, callback) {
@ -128,7 +139,7 @@ const AuthenticationManager = {
// 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) {
AuthenticationManager.setUserPassword(user._id, password, callback) AuthenticationManager.setUserPassword(user, password, callback)
} else { } else {
callback() callback()
} }
@ -143,8 +154,11 @@ const AuthenticationManager = {
}) })
}, },
setUserPasswordInV2(userId, password, callback) { setUserPasswordInV2(user, password, callback) {
const validationError = this.validatePassword(password) if (!user || !user.email || !user._id) {
return callback(new Error('invalid user object'))
}
const validationError = this.validatePassword(password, user.email)
if (validationError) { if (validationError) {
return callback(validationError) return callback(validationError)
} }
@ -154,7 +168,7 @@ const AuthenticationManager = {
} }
db.users.updateOne( db.users.updateOne(
{ {
_id: ObjectId(userId.toString()) _id: ObjectId(user._id.toString())
}, },
{ {
$set: { $set: {
@ -174,20 +188,6 @@ const AuthenticationManager = {
}) })
}, },
setUserPasswordInV1(v1UserId, password, callback) {
const validationError = this.validatePassword(password)
if (validationError) {
return callback(validationError.message)
}
V1Handler.doPasswordReset(v1UserId, password, function(error, reset) {
if (error) {
return callback(error)
}
callback(error, reset)
})
},
_passwordCharactersAreValid(password) { _passwordCharactersAreValid(password) {
let digits, letters, lettersUp, symbols let digits, letters, lettersUp, symbols
if ( if (

View file

@ -1,7 +1,6 @@
const PasswordResetHandler = require('./PasswordResetHandler') const PasswordResetHandler = require('./PasswordResetHandler')
const RateLimiter = require('../../infrastructure/RateLimiter') const RateLimiter = require('../../infrastructure/RateLimiter')
const AuthenticationController = require('../Authentication/AuthenticationController') const AuthenticationController = require('../Authentication/AuthenticationController')
const AuthenticationManager = require('../Authentication/AuthenticationManager')
const UserGetter = require('../User/UserGetter') const UserGetter = require('../User/UserGetter')
const UserUpdater = require('../User/UserUpdater') const UserUpdater = require('../User/UserUpdater')
const UserSessionsManager = require('../User/UserSessionsManager') const UserSessionsManager = require('../User/UserSessionsManager')
@ -15,9 +14,6 @@ async function setNewUserPassword(req, res, next) {
return res.sendStatus(400) return res.sendStatus(400)
} }
passwordResetToken = passwordResetToken.trim() passwordResetToken = passwordResetToken.trim()
if (AuthenticationManager.validatePassword(password) != null) {
return res.sendStatus(400)
}
delete req.session.resetToken delete req.session.resetToken
const initiatorId = AuthenticationController.getLoggedInUserId(req) const initiatorId = AuthenticationController.getLoggedInUserId(req)

View file

@ -54,7 +54,7 @@ function getUserForPasswordResetToken(token, callback) {
} }
UserGetter.getUserByMainEmail( UserGetter.getUserByMainEmail(
data.email, data.email,
{ _id: 1, 'overleaf.id': 1 }, { _id: 1, 'overleaf.id': 1, email: 1 },
(err, user) => { (err, user) => {
if (err != null) { if (err != null) {
callback(err) callback(err)
@ -93,6 +93,11 @@ async function setNewUserPassword(token, password, auditLog) {
} }
} }
const reset = await AuthenticationManager.promises.setUserPassword(
user,
password
)
await UserAuditLogHandler.promises.addEntry( await UserAuditLogHandler.promises.addEntry(
user._id, user._id,
'reset-password', 'reset-password',
@ -100,11 +105,6 @@ async function setNewUserPassword(token, password, auditLog) {
auditLog.ip auditLog.ip
) )
const reset = await AuthenticationManager.promises.setUserPassword(
user._id,
password
)
return { found: true, reset, userId: user._id } return { found: true, reset, userId: user._id }
} }

View file

@ -85,13 +85,19 @@ async function changePassword(req, res, next) {
req.i18n.translate('password_change_passwords_do_not_match') req.i18n.translate('password_change_passwords_do_not_match')
) )
} }
const validationError = AuthenticationManager.validatePassword(
req.body.newPassword1
)
if (validationError != null) {
return HttpErrorHandler.badRequest(req, res, validationError.message)
}
try {
await AuthenticationManager.promises.setUserPassword(
user,
req.body.newPassword1
)
} catch (error) {
if (error.name === 'InvalidPasswordError') {
return HttpErrorHandler.badRequest(req, res, error.message)
} else {
throw error
}
}
await UserAuditLogHandler.promises.addEntry( await UserAuditLogHandler.promises.addEntry(
user._id, user._id,
'update-password', 'update-password',
@ -99,11 +105,6 @@ async function changePassword(req, res, next) {
req.ip req.ip
) )
await AuthenticationManager.promises.setUserPassword(
user._id,
req.body.newPassword1
)
// no need to wait, errors are logged and not passed back // no need to wait, errors are logged and not passed back
_sendSecurityAlertPasswordChanged(user) _sendSecurityAlertPasswordChanged(user)

View file

@ -16,7 +16,8 @@ const UserRegistrationHandler = {
_registrationRequestIsValid(body, callback) { _registrationRequestIsValid(body, callback) {
const invalidEmail = AuthenticationManager.validateEmail(body.email || '') const invalidEmail = AuthenticationManager.validateEmail(body.email || '')
const invalidPassword = AuthenticationManager.validatePassword( const invalidPassword = AuthenticationManager.validatePassword(
body.password || '' body.password || '',
body.email
) )
if (invalidEmail != null || invalidPassword != null) { if (invalidEmail != null || invalidPassword != null) {
return false return false
@ -71,7 +72,7 @@ const UserRegistrationHandler = {
), ),
cb => cb =>
AuthenticationManager.setUserPassword( AuthenticationManager.setUserPassword(
user._id, user,
userDetails.password, userDetails.password,
cb cb
), ),

View file

@ -283,4 +283,5 @@ block content
span(ng-show="state.inflight") #{translate("deleting")}… span(ng-show="state.inflight") #{translate("deleting")}…
script(type='text/javascript'). script(type='text/javascript').
window.usersEmail = !{StringHelper.stringifyJsonForScript(user.email)};
window.passwordStrengthOptions = !{StringHelper.stringifyJsonForScript(settings.passwordStrengthOptions || {})} window.passwordStrengthOptions = !{StringHelper.stringifyJsonForScript(settings.passwordStrengthOptions || {})}

View file

@ -194,6 +194,7 @@ export default App.controller('UserAffiliationsController', function(
email.default = false email.default = false
} }
userEmail.default = true userEmail.default = true
window.usersEmail = userEmail.email
}) })
$scope.removeUserEmail = function(userEmail) { $scope.removeUserEmail = function(userEmail) {

View file

@ -20,7 +20,7 @@ class User {
} }
] ]
this.email = this.emails[0].email this.email = this.emails[0].email
this.password = `acceptance-test-${count}-password` this.password = `a-terrible-secret-${count}`
count++ count++
this.jar = request.jar() this.jar = request.jar()
this.request = request.defaults({ this.request = request.defaults({
@ -111,21 +111,17 @@ class User {
return callback(error) return callback(error)
} }
this.setExtraAttributes(user) this.setExtraAttributes(user)
AuthenticationManager.setUserPasswordInV2( AuthenticationManager.setUserPasswordInV2(user, this.password, error => {
user._id, if (error != null) {
this.password, return callback(error)
error => { }
this.mongoUpdate({ $set: { emails: this.emails } }, error => {
if (error != null) { if (error != null) {
return callback(error) return callback(error)
} }
this.mongoUpdate({ $set: { emails: this.emails } }, error => { callback(null, this.password)
if (error != null) { })
return callback(error) })
}
callback(null, this.password)
})
}
)
}) })
} }

View file

@ -26,7 +26,6 @@ describe('AuthenticationManager', function() {
}, },
bcrypt: (this.bcrypt = {}), bcrypt: (this.bcrypt = {}),
'settings-sharelatex': this.settings, 'settings-sharelatex': this.settings,
'../V1/V1Handler': (this.V1Handler = {}),
'../User/UserGetter': (this.UserGetter = {}), '../User/UserGetter': (this.UserGetter = {}),
'./AuthenticationErrors': AuthenticationErrors './AuthenticationErrors': AuthenticationErrors
} }
@ -99,6 +98,8 @@ describe('AuthenticationManager', function() {
_id: '5c8791477192a80b5e76ca7e', _id: '5c8791477192a80b5e76ca7e',
email: (this.email = 'USER@sharelatex.com') email: (this.email = 'USER@sharelatex.com')
} }
this.db.users.updateOne = sinon
this.User.findOne = sinon.stub().callsArgWith(2, null, this.user)
this.db.users.updateOne = sinon this.db.users.updateOne = sinon
.stub() .stub()
.callsArgWith(2, null, { modifiedCount: 1 }) .callsArgWith(2, null, { modifiedCount: 1 })
@ -106,7 +107,7 @@ describe('AuthenticationManager', function() {
it('should not produce an error', function(done) { it('should not produce an error', function(done) {
this.AuthenticationManager.setUserPasswordInV2( this.AuthenticationManager.setUserPasswordInV2(
this.user._id, this.user,
'testpassword', 'testpassword',
(err, updated) => { (err, updated) => {
expect(err).to.not.exist expect(err).to.not.exist
@ -118,7 +119,7 @@ describe('AuthenticationManager', function() {
it('should set the hashed password', function(done) { it('should set the hashed password', function(done) {
this.AuthenticationManager.setUserPasswordInV2( this.AuthenticationManager.setUserPasswordInV2(
this.user._id, this.user,
'testpassword', 'testpassword',
err => { err => {
expect(err).to.not.exist expect(err).to.not.exist
@ -224,7 +225,7 @@ 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.setUserPassword this.AuthenticationManager.setUserPassword
.calledWith('user-id', this.unencryptedPassword) .calledWith(this.user, this.unencryptedPassword)
.should.equal(true) .should.equal(true)
}) })
@ -258,7 +259,7 @@ 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.setUserPassword
.calledWith('user-id', this.unencryptedPassword) .calledWith(this.user, this.unencryptedPassword)
.should.equal(false) .should.equal(false)
}) })
@ -525,11 +526,16 @@ describe('AuthenticationManager', function() {
describe('setUserPassword', function() { describe('setUserPassword', function() {
beforeEach(function() { beforeEach(function() {
this.user_id = ObjectId() this.user_id = ObjectId()
this.user = {
_id: this.user_id,
email: 'user@example.com'
}
this.password = 'banana' this.password = 'banana'
this.hashedPassword = 'asdkjfa;osiuvandf' this.hashedPassword = 'asdkjfa;osiuvandf'
this.salt = 'saltaasdfasdfasdf' this.salt = 'saltaasdfasdfasdf'
this.bcrypt.genSalt = sinon.stub().callsArgWith(2, null, this.salt) this.bcrypt.genSalt = sinon.stub().callsArgWith(2, null, this.salt)
this.bcrypt.hash = sinon.stub().callsArgWith(2, null, this.hashedPassword) this.bcrypt.hash = sinon.stub().callsArgWith(2, null, this.hashedPassword)
this.User.findOne = sinon.stub().callsArgWith(2, null, this.user)
this.db.users.updateOne = sinon.stub().callsArg(2) this.db.users.updateOne = sinon.stub().callsArg(2)
}) })
@ -545,7 +551,7 @@ describe('AuthenticationManager', function() {
it('should return and error', function(done) { it('should return and error', function(done) {
this.AuthenticationManager.setUserPassword( this.AuthenticationManager.setUserPassword(
this.user_id, this.user,
this.password, this.password,
err => { err => {
expect(err).to.exist expect(err).to.exist
@ -556,7 +562,7 @@ describe('AuthenticationManager', function() {
it('should not start the bcrypt process', function(done) { it('should not start the bcrypt process', function(done) {
this.AuthenticationManager.setUserPassword( this.AuthenticationManager.setUserPassword(
this.user_id, this.user,
this.password, this.password,
() => { () => {
this.bcrypt.genSalt.called.should.equal(false) this.bcrypt.genSalt.called.should.equal(false)
@ -567,6 +573,42 @@ describe('AuthenticationManager', function() {
}) })
}) })
describe('contains full email', function() {
beforeEach(function() {
this.password = `some${this.user.email}password`
})
it('should reject the password', function(done) {
this.AuthenticationManager.setUserPassword(
this.user,
this.password,
err => {
expect(err).to.exist
expect(err.name).to.equal('InvalidPasswordError')
done()
}
)
})
})
describe('contains first part of email', function() {
beforeEach(function() {
this.password = `some${this.user.email.split('@')[0]}password`
})
it('should reject the password', function(done) {
this.AuthenticationManager.setUserPassword(
this.user,
this.password,
err => {
expect(err).to.exist
expect(err.name).to.equal('InvalidPasswordError')
done()
}
)
})
})
describe('too short', function() { describe('too short', function() {
beforeEach(function() { beforeEach(function() {
this.settings.passwordStrengthOptions = { this.settings.passwordStrengthOptions = {
@ -580,7 +622,7 @@ describe('AuthenticationManager', function() {
it('should return and error', function(done) { it('should return and error', function(done) {
this.AuthenticationManager.setUserPassword( this.AuthenticationManager.setUserPassword(
this.user_id, this.user,
this.password, this.password,
err => { err => {
expect(err).to.exist expect(err).to.exist
@ -591,7 +633,7 @@ describe('AuthenticationManager', function() {
it('should not start the bcrypt process', function(done) { it('should not start the bcrypt process', function(done) {
this.AuthenticationManager.setUserPassword( this.AuthenticationManager.setUserPassword(
this.user_id, this.user,
this.password, this.password,
() => { () => {
this.bcrypt.genSalt.called.should.equal(false) this.bcrypt.genSalt.called.should.equal(false)
@ -606,7 +648,7 @@ describe('AuthenticationManager', function() {
beforeEach(function() { beforeEach(function() {
this.UserGetter.getUser = sinon.stub().yields(null, { overleaf: null }) this.UserGetter.getUser = sinon.stub().yields(null, { overleaf: null })
this.AuthenticationManager.setUserPassword( this.AuthenticationManager.setUserPassword(
this.user_id, this.user,
this.password, this.password,
this.callback this.callback
) )

View file

@ -44,9 +44,6 @@ describe('PasswordResetController', function() {
revokeAllUserSessions: sinon.stub().resolves() revokeAllUserSessions: sinon.stub().resolves()
} }
} }
this.AuthenticationManager = {
validatePassword: sinon.stub()
}
this.UserUpdater = { this.UserUpdater = {
promises: { promises: {
removeReconfirmFlag: sinon.stub().resolves() removeReconfirmFlag: sinon.stub().resolves()
@ -70,7 +67,6 @@ describe('PasswordResetController', function() {
getLoggedInUserId: sinon.stub(), getLoggedInUserId: sinon.stub(),
finishLogin: sinon.stub() finishLogin: sinon.stub()
}), }),
'../Authentication/AuthenticationManager': this.AuthenticationManager,
'../User/UserGetter': (this.UserGetter = { '../User/UserGetter': (this.UserGetter = {
promises: { promises: {
getUser: sinon.stub() getUser: sinon.stub()
@ -248,13 +244,13 @@ describe('PasswordResetController', function() {
it('should return 400 (Bad Request) if the password is invalid', function(done) { it('should return 400 (Bad Request) if the password is invalid', function(done) {
this.req.body.password = 'correct horse battery staple' this.req.body.password = 'correct horse battery staple'
this.AuthenticationManager.validatePassword = sinon const err = new Error('bad')
.stub() err.name = 'InvalidPasswordError'
.returns({ message: 'password contains invalid characters' }) this.PasswordResetHandler.promises.setNewUserPassword.rejects(err)
this.res.sendStatus = code => { this.res.sendStatus = code => {
code.should.equal(400) code.should.equal(400)
this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal( this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
false true
) )
done() done()
} }

View file

@ -39,7 +39,6 @@ describe('PasswordResetHandler', function() {
} }
this.EmailHandler = { sendEmail: sinon.stub() } this.EmailHandler = { sendEmail: sinon.stub() }
this.AuthenticationManager = { this.AuthenticationManager = {
setUserPasswordInV1: sinon.stub(),
setUserPasswordInV2: sinon.stub(), setUserPasswordInV2: sinon.stub(),
promises: { promises: {
setUserPassword: sinon.stub().resolves() setUserPassword: sinon.stub().resolves()
@ -227,7 +226,7 @@ describe('PasswordResetHandler', function() {
email: this.email email: this.email
}) })
this.AuthenticationManager.promises.setUserPassword this.AuthenticationManager.promises.setUserPassword
.withArgs(this.user._id, this.password) .withArgs(this.user, this.password)
.resolves(true) .resolves(true)
}) })
@ -355,18 +354,18 @@ describe('PasswordResetHandler', function() {
new Error('oops') new Error('oops')
) )
}) })
it('should return the error and not update the password', function(done) { it('should return the error', function(done) {
this.PasswordResetHandler.setNewUserPassword( this.PasswordResetHandler.setNewUserPassword(
this.token, this.token,
this.password, this.password,
this.auditLog, this.auditLog,
(error, result) => { (error, _result) => {
expect(error).to.exist expect(error).to.exist
expect( expect(
this.UserAuditLogHandler.promises.addEntry.callCount this.UserAuditLogHandler.promises.addEntry.callCount
).to.equal(1) ).to.equal(1)
expect(this.AuthenticationManager.promises.setUserPassword).to expect(this.AuthenticationManager.promises.setUserPassword).to
.not.have.been.called .have.been.called
done() done()
} }
) )
@ -386,7 +385,7 @@ describe('PasswordResetHandler', function() {
email: this.email email: this.email
}) })
this.AuthenticationManager.promises.setUserPassword this.AuthenticationManager.promises.setUserPassword
.withArgs(this.user._id, this.password) .withArgs(this.user, this.password)
.resolves(true) .resolves(true)
}) })

View file

@ -664,7 +664,7 @@ describe('UserController', function() {
it('should set the new password if they do match', function(done) { it('should set the new password if they do match', function(done) {
this.res.json.callsFake(() => { this.res.json.callsFake(() => {
this.AuthenticationManager.promises.setUserPassword.should.have.been.calledWith( this.AuthenticationManager.promises.setUserPassword.should.have.been.calledWith(
this.user._id, this.user,
'newpass' 'newpass'
) )
done() done()
@ -749,9 +749,12 @@ describe('UserController', function() {
}) })
it('it should not set the new password if it is invalid', function(done) { it('it should not set the new password if it is invalid', function(done) {
this.AuthenticationManager.validatePassword = sinon // this.AuthenticationManager.validatePassword = sinon
.stub() // .stub()
.returns({ message: 'validation-error' }) // .returns({ message: 'validation-error' })
const err = new Error('bad')
err.name = 'InvalidPasswordError'
this.AuthenticationManager.promises.setUserPassword.rejects(err)
this.AuthenticationManager.promises.authenticate.resolves({}) this.AuthenticationManager.promises.authenticate.resolves({})
this.req.body = { this.req.body = {
newPassword1: 'newpass', newPassword1: 'newpass',
@ -761,10 +764,10 @@ describe('UserController', function() {
expect(this.HttpErrorHandler.badRequest).to.have.been.calledWith( expect(this.HttpErrorHandler.badRequest).to.have.been.calledWith(
this.req, this.req,
this.res, this.res,
'validation-error' err.message
) )
this.AuthenticationManager.promises.setUserPassword.callCount.should.equal( this.AuthenticationManager.promises.setUserPassword.callCount.should.equal(
0 1
) )
done() done()
}) })
@ -784,7 +787,7 @@ describe('UserController', function() {
this.UserController.changePassword(this.req, this.res, error => { this.UserController.changePassword(this.req, this.res, error => {
expect(error).to.be.instanceof(Error) expect(error).to.be.instanceof(Error)
this.AuthenticationManager.promises.setUserPassword.callCount.should.equal( this.AuthenticationManager.promises.setUserPassword.callCount.should.equal(
0 1
) )
done() done()
}) })

View file

@ -208,7 +208,7 @@ describe('UserRegistrationHandler', function() {
it('should set the password', function(done) { it('should set the password', function(done) {
return this.handler.registerNewUser(this.passingRequest, err => { return this.handler.registerNewUser(this.passingRequest, err => {
this.AuthenticationManager.setUserPassword this.AuthenticationManager.setUserPassword
.calledWith(this.user._id, this.passingRequest.password) .calledWith(this.user, this.passingRequest.password)
.should.equal(true) .should.equal(true)
return done() return done()
}) })