[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
@@ -180,7 +180,8 @@ describe('AuthenticationManager', function () {
|
||||
email: (this.email = 'USER@sharelatex.com'),
|
||||
}
|
||||
this.db.users.updateOne = sinon
|
||||
this.User.findOne = sinon.stub().callsArgWith(2, null, this.user)
|
||||
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user)
|
||||
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, false)
|
||||
this.db.users.updateOne = sinon
|
||||
.stub()
|
||||
.callsArgWith(2, null, { modifiedCount: 1 })
|
||||
@@ -603,19 +604,39 @@ describe('AuthenticationManager', function () {
|
||||
describe('setUserPassword', function () {
|
||||
beforeEach(function () {
|
||||
this.user_id = ObjectId()
|
||||
this.user = {
|
||||
_id: this.user_id,
|
||||
email: 'user@example.com',
|
||||
}
|
||||
this.password = 'banana'
|
||||
this.hashedPassword = 'asdkjfa;osiuvandf'
|
||||
this.salt = 'saltaasdfasdfasdf'
|
||||
this.user = {
|
||||
_id: this.user_id,
|
||||
email: 'user@example.com',
|
||||
hashedPassword: this.hashedPassword,
|
||||
}
|
||||
this.bcrypt.compare = sinon.stub().callsArgWith(2, null, false)
|
||||
this.bcrypt.genSalt = sinon.stub().callsArgWith(2, null, this.salt)
|
||||
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(1, null, this.user)
|
||||
this.db.users.updateOne = sinon.stub().callsArg(2)
|
||||
})
|
||||
|
||||
describe('same as previous password', function () {
|
||||
beforeEach(function () {
|
||||
this.bcrypt.compare.callsArgWith(2, null, true)
|
||||
})
|
||||
|
||||
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 () {
|
||||
beforeEach(function () {
|
||||
this.settings.passwordStrengthOptions = {
|
||||
|
||||
@@ -39,6 +39,10 @@ describe('PasswordResetController', function () {
|
||||
.stub()
|
||||
.resolves({ found: true, reset: true, userID: this.user_id }),
|
||||
},
|
||||
getUserForPasswordResetToken: sinon
|
||||
.stub()
|
||||
.withArgs(this.token)
|
||||
.yields(null, { _id: this.user_id }, 1),
|
||||
}
|
||||
this.UserSessionsManager = {
|
||||
promises: {
|
||||
@@ -372,6 +376,28 @@ describe('PasswordResetController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('with expired token in query', function () {
|
||||
beforeEach(function () {
|
||||
this.req.query.passwordResetToken = this.token
|
||||
this.PasswordResetHandler.getUserForPasswordResetToken = sinon
|
||||
.stub()
|
||||
.withArgs(this.token)
|
||||
.yields(null, { _id: this.user_id }, 0)
|
||||
})
|
||||
|
||||
it('should redirect to the reset request page with an error message', function (done) {
|
||||
this.res.redirect = path => {
|
||||
path.should.equal('/user/password/reset?error=token_expired')
|
||||
this.req.session.should.not.have.property('resetToken')
|
||||
done()
|
||||
}
|
||||
this.res.render = (templatePath, options) => {
|
||||
done('should not render')
|
||||
}
|
||||
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with token and email in query-string', function () {
|
||||
beforeEach(function () {
|
||||
this.req.query.passwordResetToken = this.token
|
||||
|
||||
@@ -26,7 +26,8 @@ describe('PasswordResetHandler', function () {
|
||||
this.settings = { siteUrl: 'https://www.overleaf.com' }
|
||||
this.OneTimeTokenHandler = {
|
||||
getNewToken: sinon.stub(),
|
||||
getValueFromTokenAndExpire: sinon.stub(),
|
||||
peekValueFromToken: sinon.stub(),
|
||||
expireToken: sinon.stub(),
|
||||
}
|
||||
this.UserGetter = {
|
||||
getUserByMainEmail: sinon.stub(),
|
||||
@@ -188,7 +189,7 @@ describe('PasswordResetHandler', function () {
|
||||
})
|
||||
describe('when no data is found', function () {
|
||||
beforeEach(function () {
|
||||
this.OneTimeTokenHandler.getValueFromTokenAndExpire.yields(null, null)
|
||||
this.OneTimeTokenHandler.peekValueFromToken.yields(null, null)
|
||||
})
|
||||
|
||||
it('should return found == false and reset == false', function () {
|
||||
@@ -210,7 +211,7 @@ describe('PasswordResetHandler', function () {
|
||||
|
||||
describe('when the token has a user_id and email', function () {
|
||||
beforeEach(function () {
|
||||
this.OneTimeTokenHandler.getValueFromTokenAndExpire
|
||||
this.OneTimeTokenHandler.peekValueFromToken
|
||||
.withArgs('password', this.token)
|
||||
.yields(null, {
|
||||
user_id: this.user._id,
|
||||
@@ -219,6 +220,9 @@ describe('PasswordResetHandler', function () {
|
||||
this.AuthenticationManager.promises.setUserPassword
|
||||
.withArgs(this.user, this.password)
|
||||
.resolves(true)
|
||||
this.OneTimeTokenHandler.expireToken = sinon
|
||||
.stub()
|
||||
.callsArgWith(2, null)
|
||||
})
|
||||
|
||||
describe('when no user is found with this email', function () {
|
||||
@@ -238,6 +242,7 @@ describe('PasswordResetHandler', function () {
|
||||
expect(err).to.not.exist
|
||||
expect(found).to.be.false
|
||||
expect(reset).to.be.false
|
||||
expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0)
|
||||
done()
|
||||
}
|
||||
)
|
||||
@@ -249,6 +254,7 @@ describe('PasswordResetHandler', function () {
|
||||
this.UserGetter.getUserByMainEmail
|
||||
.withArgs(this.email)
|
||||
.yields(null, { _id: 'not-the-same', email: this.email })
|
||||
this.OneTimeTokenHandler.expireToken.callsArgWith(2, null)
|
||||
})
|
||||
|
||||
it('should return found == false and reset == false', function (done) {
|
||||
@@ -261,6 +267,7 @@ describe('PasswordResetHandler', function () {
|
||||
expect(err).to.not.exist
|
||||
expect(found).to.be.false
|
||||
expect(reset).to.be.false
|
||||
expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0)
|
||||
done()
|
||||
}
|
||||
)
|
||||
@@ -271,6 +278,9 @@ describe('PasswordResetHandler', function () {
|
||||
describe('success', function () {
|
||||
beforeEach(function () {
|
||||
this.UserGetter.getUserByMainEmail.yields(null, this.user)
|
||||
this.OneTimeTokenHandler.expireToken = sinon
|
||||
.stub()
|
||||
.callsArgWith(2, null)
|
||||
})
|
||||
|
||||
it('should update the user audit log', function (done) {
|
||||
@@ -308,6 +318,20 @@ describe('PasswordResetHandler', function () {
|
||||
)
|
||||
})
|
||||
|
||||
it('should expire the token', function (done) {
|
||||
this.PasswordResetHandler.setNewUserPassword(
|
||||
this.token,
|
||||
this.password,
|
||||
this.auditLog,
|
||||
(_err, _result) => {
|
||||
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
|
||||
true
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('when logged in', function () {
|
||||
beforeEach(function () {
|
||||
this.auditLog.initiatorId = this.user_id
|
||||
@@ -335,6 +359,30 @@ describe('PasswordResetHandler', 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(1)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('via UserAuditLogHandler', function () {
|
||||
beforeEach(function () {
|
||||
this.PasswordResetHandler.promises.getUserForPasswordResetToken =
|
||||
@@ -367,7 +415,7 @@ describe('PasswordResetHandler', function () {
|
||||
describe('when the token has a v1_user_id and email', function () {
|
||||
beforeEach(function () {
|
||||
this.user.overleaf = { id: 184 }
|
||||
this.OneTimeTokenHandler.getValueFromTokenAndExpire
|
||||
this.OneTimeTokenHandler.peekValueFromToken
|
||||
.withArgs('password', this.token)
|
||||
.yields(null, {
|
||||
v1_user_id: this.user.overleaf.id,
|
||||
@@ -376,6 +424,9 @@ describe('PasswordResetHandler', function () {
|
||||
this.AuthenticationManager.promises.setUserPassword
|
||||
.withArgs(this.user, this.password)
|
||||
.resolves(true)
|
||||
this.OneTimeTokenHandler.expireToken = sinon
|
||||
.stub()
|
||||
.callsArgWith(2, null)
|
||||
})
|
||||
|
||||
describe('when no user is reset with this email', function () {
|
||||
@@ -394,6 +445,9 @@ describe('PasswordResetHandler', function () {
|
||||
const { reset, userId } = result
|
||||
expect(err).to.not.exist
|
||||
expect(reset).to.be.false
|
||||
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
|
||||
false
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
@@ -418,6 +472,9 @@ describe('PasswordResetHandler', function () {
|
||||
const { reset, userId } = result
|
||||
expect(err).to.not.exist
|
||||
expect(reset).to.be.false
|
||||
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
|
||||
false
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
@@ -441,6 +498,7 @@ describe('PasswordResetHandler', function () {
|
||||
expect(err).to.not.exist
|
||||
expect(reset).to.be.true
|
||||
expect(userId).to.equal(this.user._id)
|
||||
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(true)
|
||||
done()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -107,6 +107,90 @@ describe('OneTimeTokenHandler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('peekValueFromToken', function () {
|
||||
describe('successfully', function () {
|
||||
const data = 'some-mock-data'
|
||||
beforeEach(function () {
|
||||
this.db.tokens.findOneAndUpdate = sinon
|
||||
.stub()
|
||||
.yields(null, { value: { data } })
|
||||
return this.OneTimeTokenHandler.peekValueFromToken(
|
||||
'password',
|
||||
'mock-token',
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should increment the peekCount', function () {
|
||||
return this.db.tokens.findOneAndUpdate
|
||||
.calledWith(
|
||||
{
|
||||
use: 'password',
|
||||
token: 'mock-token',
|
||||
expiresAt: { $gt: new Date() },
|
||||
usedAt: { $exists: false },
|
||||
peekCount: { $not: { $gte: this.OneTimeTokenHandler.MAX_PEEKS } },
|
||||
},
|
||||
{
|
||||
$inc: { peekCount: 1 },
|
||||
}
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the data', function () {
|
||||
return this.callback.calledWith(null, data).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a valid token is not found', function () {
|
||||
beforeEach(function () {
|
||||
this.db.tokens.findOneAndUpdate = sinon
|
||||
.stub()
|
||||
.yields(null, { value: null })
|
||||
return this.OneTimeTokenHandler.peekValueFromToken(
|
||||
'password',
|
||||
'mock-token',
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return a NotFoundError', function () {
|
||||
return this.callback
|
||||
.calledWith(sinon.match.instanceOf(Errors.NotFoundError))
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('expireToken', function () {
|
||||
beforeEach(function () {
|
||||
this.db.tokens.updateOne = sinon.stub().yields(null)
|
||||
this.OneTimeTokenHandler.expireToken(
|
||||
'password',
|
||||
'mock-token',
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should expire the token', function () {
|
||||
this.db.tokens.updateOne
|
||||
.calledWith(
|
||||
{
|
||||
use: 'password',
|
||||
token: 'mock-token',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
usedAt: new Date(),
|
||||
},
|
||||
}
|
||||
)
|
||||
.should.equal(true)
|
||||
this.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getValueFromTokenAndExpire', function () {
|
||||
describe('successfully', function () {
|
||||
beforeEach(function () {
|
||||
@@ -128,6 +212,7 @@ describe('OneTimeTokenHandler', function () {
|
||||
token: 'mock-token',
|
||||
expiresAt: { $gt: new Date() },
|
||||
usedAt: { $exists: false },
|
||||
peekCount: { $not: { $gte: this.OneTimeTokenHandler.MAX_PEEKS } },
|
||||
},
|
||||
{
|
||||
$set: { usedAt: new Date() },
|
||||
|
||||
Reference in New Issue
Block a user