Merge pull request #8882 from overleaf/jk-web-reject-same-password

[web] Password set/reset: reject current password

GitOrigin-RevId: 2c40dda4926d9c68564ae5126b3393b9286bb661
This commit is contained in:
June Kelly 2022-07-19 09:32:23 +01:00 committed by Copybot
parent 7f6e2c971e
commit d04ea76081
12 changed files with 148 additions and 36 deletions

View file

@ -3,9 +3,11 @@ const Errors = require('../Errors/Errors')
class InvalidEmailError extends Errors.BackwardCompatibleError {} class InvalidEmailError extends Errors.BackwardCompatibleError {}
class InvalidPasswordError extends Errors.BackwardCompatibleError {} class InvalidPasswordError extends Errors.BackwardCompatibleError {}
class ParallelLoginError extends Errors.BackwardCompatibleError {} class ParallelLoginError extends Errors.BackwardCompatibleError {}
class PasswordMustBeDifferentError extends Errors.BackwardCompatibleError {}
module.exports = { module.exports = {
InvalidEmailError, InvalidEmailError,
InvalidPasswordError, InvalidPasswordError,
ParallelLoginError, ParallelLoginError,
PasswordMustBeDifferentError,
} }

View file

@ -7,6 +7,7 @@ const {
InvalidEmailError, InvalidEmailError,
InvalidPasswordError, InvalidPasswordError,
ParallelLoginError, ParallelLoginError,
PasswordMustBeDifferentError,
} = require('./AuthenticationErrors') } = require('./AuthenticationErrors')
const util = require('util') const util = require('util')
const HaveIBeenPwned = require('./HaveIBeenPwned') const HaveIBeenPwned = require('./HaveIBeenPwned')
@ -182,31 +183,45 @@ const AuthenticationManager = {
if (validationError) { if (validationError) {
return callback(validationError) return callback(validationError)
} }
this.hashPassword(password, function (error, hash) { // check if we can log in with this password. In which case we should reject it,
if (error) { // because it is the same as the existing password.
return callback(error) AuthenticationManager.authenticate(
} { _id: user._id },
db.users.updateOne( password,
{ (err, authedUser) => {
_id: ObjectId(user._id.toString()), if (err) {
}, return callback(err)
{
$set: {
hashedPassword: hash,
},
$unset: {
password: true,
},
},
function (updateError, result) {
if (updateError) {
return callback(updateError)
}
_checkWriteResult(result, callback)
HaveIBeenPwned.checkPasswordForReuseInBackground(password)
} }
) if (authedUser) {
}) return callback(new PasswordMustBeDifferentError())
}
this.hashPassword(password, function (error, hash) {
if (error) {
return callback(error)
}
db.users.updateOne(
{
_id: ObjectId(user._id.toString()),
},
{
$set: {
hashedPassword: hash,
},
$unset: {
password: true,
},
},
function (updateError, result) {
if (updateError) {
return callback(updateError)
}
_checkWriteResult(result, callback)
HaveIBeenPwned.checkPasswordForReuseInBackground(password)
}
)
})
}
)
}, },
_passwordCharactersAreValid(password) { _passwordCharactersAreValid(password) {

View file

@ -78,6 +78,12 @@ async function setNewUserPassword(req, res, next) {
key: 'invalid-password', key: 'invalid-password',
}, },
}) })
} else if (error.name === 'PasswordMustBeDifferentError') {
return res.status(400).json({
message: {
key: 'password-must-be-different',
},
})
} else { } else {
return res.status(500).json({ return res.status(500).json({
message: req.i18n.translate('error_performing_request'), message: req.i18n.translate('error_performing_request'),

View file

@ -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,
password
)
return { found: true, reset, userId: user._id } return { found: true, reset, userId: user._id }
} }

View file

@ -96,6 +96,12 @@ async function changePassword(req, res, next) {
} catch (error) { } catch (error) {
if (error.name === 'InvalidPasswordError') { if (error.name === 'InvalidPasswordError') {
return HttpErrorHandler.badRequest(req, res, error.message) return HttpErrorHandler.badRequest(req, res, error.message)
} else if (error.name === 'PasswordMustBeDifferentError') {
return HttpErrorHandler.badRequest(
req,
res,
req.i18n.translate('password_change_password_must_be_different')
)
} else { } else {
throw error throw error
} }

View file

@ -26,6 +26,11 @@ block content
+customFormMessage('invalid-password', 'danger') +customFormMessage('invalid-password', 'danger')
| #{translate('invalid_password')} | #{translate('invalid_password')}
+customFormMessage('password-must-be-different', 'danger')
| #{translate('password_change_password_must_be_different')}
br
a(href='/user/password/reset') #{translate("request_new_password_reset_email")}
div.alert.alert-success( div.alert.alert-success(
hidden hidden
role="alert" role="alert"

View file

@ -328,6 +328,7 @@
"not_found_error_from_the_supplied_url": "The link to open this content on Overleaf pointed to a file that could not be found. If this keeps happening for links on a particular site, please report this to them.", "not_found_error_from_the_supplied_url": "The link to open this content on Overleaf pointed to a file that could not be found. If this keeps happening for links on a particular site, please report this to them.",
"too_many_requests": "Too many requests were received in a short space of time. Please wait for a few moments and try again.", "too_many_requests": "Too many requests were received in a short space of time. Please wait for a few moments and try again.",
"password_change_passwords_do_not_match": "Passwords do not match", "password_change_passwords_do_not_match": "Passwords do not match",
"password_change_password_must_be_different": "The password you entered is the same as your current password. Please try a different password.",
"password_change_old_password_wrong": "Your old password is wrong", "password_change_old_password_wrong": "Your old password is wrong",
"github_for_link_shared_projects": "This project was accessed via link-sharing and wont be synchronised with your GitHub unless you are invited via e-mail by the project owner.", "github_for_link_shared_projects": "This project was accessed via link-sharing and wont be synchronised with your GitHub unless you are invited via e-mail by the project owner.",
"browsing_project_latest_for_pseudo_label": "Browsing your projects current state", "browsing_project_latest_for_pseudo_label": "Browsing your projects current state",

View file

@ -234,6 +234,25 @@ describe('PasswordReset', function () {
const auditLog = userHelper.getAuditLogWithoutNoise() const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog.length).to.equal(1) expect(auditLog.length).to.equal(1)
}) })
it('when the password is the same as current, should return 400 and not log the change', async function () {
// send reset request
response = await userHelper.request.post('/user/password/set', {
form: {
passwordResetToken: token,
password: userHelper.getDefaultPassword(),
},
simple: false,
})
expect(response.statusCode).to.equal(400)
expect(JSON.parse(response.body).message.key).to.equal(
'password-must-be-different'
)
userHelper = await UserHelper.getUser({ email })
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
}) })
}) })
describe('without a valid token', function () { describe('without a valid token', function () {

View file

@ -256,7 +256,7 @@ describe('Sessions', function () {
// password reset from second session, should erase two of the three sessions // password reset from second session, should erase two of the three sessions
next => { next => {
this.user2.changePassword(err => next(err)) this.user2.changePassword(`password${Date.now()}`, err => next(err))
}, },
next => { next => {

View file

@ -152,7 +152,9 @@ class User {
this.setExtraAttributes(user) this.setExtraAttributes(user)
AuthenticationManager.setUserPasswordInV2(user, this.password, error => { AuthenticationManager.setUserPasswordInV2(user, this.password, error => {
if (error != null) { if (error != null) {
return callback(error) if (error.name !== 'PasswordMustBeDifferentError') {
return callback(error)
}
} }
this.mongoUpdate({ $set: { emails: this.emails } }, error => { this.mongoUpdate({ $set: { emails: this.emails } }, error => {
if (error != null) { if (error != null) {
@ -675,7 +677,7 @@ class User {
) )
} }
changePassword(callback) { changePassword(newPassword, callback) {
this.getCsrfToken(error => { this.getCsrfToken(error => {
if (error != null) { if (error != null) {
return callback(error) return callback(error)
@ -685,11 +687,17 @@ class User {
url: '/user/password/update', url: '/user/password/update',
json: { json: {
currentPassword: this.password, currentPassword: this.password,
newPassword1: this.password, newPassword1: newPassword,
newPassword2: this.password, newPassword2: newPassword,
}, },
}, },
callback err => {
if (err) {
return callback(err)
}
this.password = newPassword
callback()
}
) )
}) })
} }

View file

@ -181,6 +181,9 @@ describe('AuthenticationManager', function () {
} }
this.db.users.updateOne = sinon this.db.users.updateOne = sinon
this.User.findOne = sinon.stub().callsArgWith(2, null, this.user) this.User.findOne = sinon.stub().callsArgWith(2, null, this.user)
this.AuthenticationManager.authenticate = sinon
.stub()
.callsArgWith(2, null, null)
this.db.users.updateOne = sinon this.db.users.updateOne = sinon
.stub() .stub()
.callsArgWith(2, null, { modifiedCount: 1 }) .callsArgWith(2, null, { modifiedCount: 1 })
@ -614,6 +617,29 @@ describe('AuthenticationManager', function () {
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.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)
this.AuthenticationManager.authenticate = sinon
.stub()
.callsArgWith(2, null, null)
})
describe('same as previous password', function () {
beforeEach(function () {
this.AuthenticationManager.authenticate = sinon
.stub()
.callsArgWith(2, null, this.user)
})
it('should return an error', function (done) {
this.AuthenticationManager.setUserPassword(
this.user,
this.password,
err => {
expect(err).to.exist
expect(err.name).to.equal('PasswordMustBeDifferentError')
done()
}
)
})
}) })
describe('too long', function () { describe('too long', function () {

View file

@ -335,6 +335,30 @@ describe('PasswordResetHandler', function () {
}) })
describe('errors', function () { describe('errors', function () {
describe('via setUserPassword', function () {
beforeEach(function () {
this.PasswordResetHandler.promises.getUserForPasswordResetToken =
sinon.stub().withArgs(this.token).resolves(this.user)
this.AuthenticationManager.promises.setUserPassword
.withArgs(this.user, this.password)
.rejects()
})
it('should return the error', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(error, _result) => {
expect(error).to.exist
expect(
this.UserAuditLogHandler.promises.addEntry.callCount
).to.equal(0)
done()
}
)
})
})
describe('via UserAuditLogHandler', function () { describe('via UserAuditLogHandler', function () {
beforeEach(function () { beforeEach(function () {
this.PasswordResetHandler.promises.getUserForPasswordResetToken = this.PasswordResetHandler.promises.getUserForPasswordResetToken =
@ -354,7 +378,7 @@ describe('PasswordResetHandler', function () {
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()
} }
) )