[web] Password set/reset: reject current password (redux) (#8956)

* [web] set-password: reject same as current password

* [web] Add 'peek' operation on tokens

This allows us to improve the UX of the reset-password form,
by not invalidating the token in the case where the new
password will be rejected by validation logic.

We give up to three attempts before invalidating the token.

* [web] Add hide-on-error feature to async forms

This allows us to hide the form elements when certain
named error conditions occur.

* [web] reset-password: handle same-password rejection

We also change the implementation to use the new
peekValueFromToken API, and to expire the token explicitely
after it has been used to set the new password.

* [web] Validate OneTimeToken when loading password reset form

* [web] Rate limit GET: /user/password/set

Now that we are peeking at OneTimeToken when accessing this page,
we add rate to the GET request, matching that of the POST request.

* [web] Tidy up pug layout and mongo query for token peeking

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>
GitOrigin-RevId: 835205cc7c7ebe1209ee8e5b693efeb939a3056a
This commit is contained in:
June Kelly
2022-09-28 08:06:54 +00:00
committed by Copybot
co-authored by Mathias Jakobsen
parent b5e2604041
commit 3288f87dbe
18 changed files with 568 additions and 99 deletions
@@ -227,8 +227,119 @@ describe('PasswordReset', function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog.length).to.equal(1)
})
it('when the password is the same as current, should return 400 and 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.length).to.equal(1)
})
})
})
describe('multiple attempts to set the password, reaching attempt limit', async function () {
beforeEach(async function () {
response = await userHelper.request.get(
`/user/password/set?passwordResetToken=${token}&email=${email}`,
{ simple: false }
)
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal(
`/user/password/set${emailQuery}`
)
})
it('should allow multiple attempts with same-password error, then deny further attempts', async function () {
const sendSamePasswordRequest = async function () {
return userHelper.request.post('/user/password/set', {
form: {
passwordResetToken: token,
password: userHelper.getDefaultPassword(),
},
simple: false,
})
}
// Three attempts at setting the password, all rejected for being the same as
// the current password
const response1 = await sendSamePasswordRequest()
expect(response1.statusCode).to.equal(400)
expect(JSON.parse(response1.body).message.key).to.equal(
'password-must-be-different'
)
const response2 = await sendSamePasswordRequest()
expect(response2.statusCode).to.equal(400)
expect(JSON.parse(response2.body).message.key).to.equal(
'password-must-be-different'
)
const response3 = await sendSamePasswordRequest()
expect(response3.statusCode).to.equal(400)
expect(JSON.parse(response3.body).message.key).to.equal(
'password-must-be-different'
)
// Fourth attempt is rejected because the token has been used too many times
const response4 = await sendSamePasswordRequest()
expect(response4.statusCode).to.equal(404)
expect(JSON.parse(response4.body).message.key).to.equal('token-expired')
})
it('should allow multiple attempts with same-password error, then set the password', async function () {
const sendSamePasswordRequest = async function () {
return userHelper.request.post('/user/password/set', {
form: {
passwordResetToken: token,
password: userHelper.getDefaultPassword(),
},
simple: false,
})
}
// Two attempts at setting the password, all rejected for being the same as
// the current password
const response1 = await sendSamePasswordRequest()
expect(response1.statusCode).to.equal(400)
expect(JSON.parse(response1.body).message.key).to.equal(
'password-must-be-different'
)
const response2 = await sendSamePasswordRequest()
expect(response2.statusCode).to.equal(400)
expect(JSON.parse(response2.body).message.key).to.equal(
'password-must-be-different'
)
// Third attempt is succeeds
const response3 = await userHelper.request.post('/user/password/set', {
form: {
passwordResetToken: token,
password: 'some-new-password',
},
simple: false,
})
expect(response3.statusCode).to.equal(200)
// Check the user and audit log
userHelper = await UserHelper.getUser({ email })
user = userHelper.user
expect(user.hashedPassword).to.exist
expect(user.password).to.not.exist
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.exist
expect(auditLog[0]).to.exist
expect(auditLog[0].initiatorId).to.equal(null)
expect(auditLog[0].operation).to.equal('reset-password')
expect(auditLog[0].ipAddress).to.equal('127.0.0.1')
expect(auditLog[0].timestamp).to.exist
})
})
describe('without a valid token', function () {
it('no token should redirect to page to re-request reset token', async function () {
response = await userHelper.request.get(
@@ -238,7 +349,7 @@ describe('PasswordReset', function () {
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal('/user/password/reset')
})
it('should return 404 for invalid tokens', async function () {
it('should show error for invalid tokens and return 404 if used', async function () {
const invalidToken = 'not-real-token'
response = await userHelper.request.get(
`/user/password/set?&passwordResetToken=${invalidToken}&email=${email}`,
@@ -246,7 +357,7 @@ describe('PasswordReset', function () {
)
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal(
`/user/password/set${emailQuery}`
`/user/password/reset?error=token_expired`
)
// send reset request
response = await userHelper.request.post('/user/password/set', {
@@ -256,7 +256,7 @@ describe('Sessions', function () {
// password reset from second session, should erase two of the three sessions
next => {
this.user2.changePassword(err => next(err))
this.user2.changePassword(`password${Date.now()}`, err => next(err))
},
next => {
@@ -152,7 +152,9 @@ class User {
this.setExtraAttributes(user)
AuthenticationManager.setUserPasswordInV2(user, this.password, error => {
if (error != null) {
return callback(error)
if (error.name !== 'PasswordMustBeDifferentError') {
return callback(error)
}
}
this.mongoUpdate({ $set: { emails: this.emails } }, error => {
if (error != null) {
@@ -675,7 +677,7 @@ class User {
)
}
changePassword(callback) {
changePassword(newPassword, callback) {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
@@ -685,11 +687,17 @@ class User {
url: '/user/password/update',
json: {
currentPassword: this.password,
newPassword1: this.password,
newPassword2: this.password,
newPassword1: newPassword,
newPassword2: newPassword,
},
},
callback
err => {
if (err) {
return callback(err)
}
this.password = newPassword
callback()
}
)
})
}