[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:
committed by
Copybot
co-authored by
Mathias Jakobsen
parent
b5e2604041
commit
3288f87dbe
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user