Merge pull request #12342 from overleaf/jk-password-ux-please-use-another-password

[web] Password UX: 'Please use another password'

GitOrigin-RevId: ca9b26cbcf2dabb27c716da314764ee40ffc83dd
This commit is contained in:
June Kelly 2023-04-11 14:16:51 +01:00 committed by Copybot
parent 9362d286b7
commit 841df71a1d
10 changed files with 137 additions and 45 deletions

View file

@ -402,6 +402,48 @@ const AuthenticationManager = {
} }
} }
}, },
getMessageForInvalidPasswordError(error, req) {
const errorCode = error?.info?.code
const message = {
type: 'error',
}
switch (errorCode) {
case 'not_set':
message.key = 'password-not-set'
message.text = req.i18n.translate('invalid_password_not_set')
break
case 'invalid_character':
message.key = 'password-invalid-character'
message.text = req.i18n.translate('invalid_password_invalid_character')
break
case 'contains_email':
message.key = 'password-contains-email'
message.text = req.i18n.translate('invalid_password_contains_email')
break
case 'too_similar':
message.key = 'password-too-similar'
message.text = req.i18n.translate('invalid_password_too_similar')
break
case 'too_short':
message.key = 'password-too-short'
message.text = req.i18n.translate('invalid_password_too_short', {
minLength: Settings.passwordStrengthOptions?.length?.min || 8,
})
break
case 'too_long':
message.key = 'password-too-long'
message.text = req.i18n.translate('invalid_password_too_long', {
maxLength: Settings.passwordStrengthOptions?.length?.max || 72,
})
break
default:
logger.error({ err: error }, 'Unknown password validation error code')
message.text = req.i18n.translate('invalid_password')
break
}
return message
},
} }
AuthenticationManager.promises = { AuthenticationManager.promises = {

View file

@ -22,23 +22,11 @@ async function setNewUserPassword(req, res, next) {
const err = AuthenticationManager.validatePassword(password, email) const err = AuthenticationManager.validatePassword(password, email)
if (err) { if (err) {
if (err?.info?.code === 'contains_email') { const message = AuthenticationManager.getMessageForInvalidPasswordError(
return res.status(400).json({ err,
message: { req
text: req.i18n.translate('invalid_password_contains_email'), )
}, return res.status(400).json({ message })
})
} else if (err?.info?.code === 'too_similar') {
return res.status(400).json({
message: {
text: req.i18n.translate('invalid_password_too_similar'),
},
})
} else {
return res.status(400).json({
message: { text: err.message },
})
}
} }
passwordResetToken = passwordResetToken.trim() passwordResetToken = passwordResetToken.trim()

View file

@ -96,21 +96,11 @@ async function changePassword(req, res, next) {
) )
} catch (error) { } catch (error) {
if (error.name === 'InvalidPasswordError') { if (error.name === 'InvalidPasswordError') {
if (error?.info?.code === 'contains_email') { const message = AuthenticationManager.getMessageForInvalidPasswordError(
return HttpErrorHandler.badRequest( error,
req, req
res,
req.i18n.translate('invalid_password_contains_email')
) )
} else if (error?.info?.code === 'too_similar') { return res.status(400).json({ message })
return HttpErrorHandler.badRequest(
req,
res,
req.i18n.translate('invalid_password_too_similar')
)
} else {
return HttpErrorHandler.badRequest(req, res, error.message)
}
} else if (error.name === 'PasswordMustBeDifferentError') { } else if (error.name === 'PasswordMustBeDifferentError') {
return HttpErrorHandler.badRequest( return HttpErrorHandler.badRequest(
req, req,

View file

@ -24,6 +24,14 @@ block content
p(data-ol-hide-on-error-message="token-expired") #{translate("create_a_new_password_for_your_account")}. p(data-ol-hide-on-error-message="token-expired") #{translate("create_a_new_password_for_your_account")}.
+formMessages() +formMessages()
+customFormMessage('password-contains-email', 'danger')
| #{translate('invalid_password_contains_email')}.
| #{translate('use_a_different_password')}
+customFormMessage('password-too-similar', 'danger')
| #{translate('invalid_password_too_similar')}.
| #{translate('use_a_different_password')}
+customFormMessage('token-expired', 'danger') +customFormMessage('token-expired', 'danger')
| #{translate('password_reset_token_expired')} | #{translate('password_reset_token_expired')}
br br

View file

@ -410,6 +410,8 @@
"invalid_email": "", "invalid_email": "",
"invalid_file_name": "", "invalid_file_name": "",
"invalid_filename": "", "invalid_filename": "",
"invalid_password_contains_email": "",
"invalid_password_too_similar": "",
"invalid_request": "", "invalid_request": "",
"invite_more_collabs": "", "invite_more_collabs": "",
"invite_not_accepted": "", "invite_not_accepted": "",

View file

@ -164,6 +164,16 @@ function PasswordForm() {
/> />
. {t('use_a_different_password')} . {t('use_a_different_password')}
</> </>
) : getErrorMessageKey(error) === 'password-contains-email' ? (
<>
{t('invalid_password_contains_email')}.{' '}
{t('use_a_different_password')}
</>
) : getErrorMessageKey(error) === 'password-too-similar' ? (
<>
{t('invalid_password_too_similar')}.{' '}
{t('use_a_different_password')}
</>
) : ( ) : (
getUserFacingMessage(error) getUserFacingMessage(error)
)} )}

View file

@ -751,7 +751,7 @@
"invalid_password_not_set": "Password is required", "invalid_password_not_set": "Password is required",
"invalid_password_too_long": "Maximum password length __maxLength__ exceeded", "invalid_password_too_long": "Maximum password length __maxLength__ exceeded",
"invalid_password_too_short": "Password too short, minimum __minLength__", "invalid_password_too_short": "Password too short, minimum __minLength__",
"invalid_password_too_similar": "Password is too similar to email address", "invalid_password_too_similar": "Password is too similar to parts of email address",
"invalid_request": "Invalid Request. Please correct the data and try again.", "invalid_request": "Invalid Request. Please correct the data and try again.",
"invalid_zip_file": "Invalid zip file", "invalid_zip_file": "Invalid zip file",
"invite_more_collabs": "Invite more collaborators", "invite_more_collabs": "Invite more collaborators",

View file

@ -200,7 +200,11 @@ describe('PasswordReset', function () {
expect(response.status).to.equal(400) expect(response.status).to.equal(400)
const body = await response.json() const body = await response.json()
expect(body).to.deep.equal({ expect(body).to.deep.equal({
message: { text: 'Password cannot contain parts of email address' }, message: {
type: 'error',
key: 'password-contains-email',
text: 'Password cannot contain parts of email address',
},
}) })
}) })
@ -223,7 +227,11 @@ describe('PasswordReset', function () {
expect(response.status).to.equal(400) expect(response.status).to.equal(400)
const body = await response.json() const body = await response.json()
expect(body).to.deep.equal({ expect(body).to.deep.equal({
message: { text: 'Password is too similar to email address' }, message: {
type: 'error',
key: 'password-too-similar',
text: 'Password is too similar to parts of email address',
},
}) })
}) })

View file

@ -133,7 +133,43 @@ describe('PasswordUpdate', function () {
}) })
it('should return error message', async function () { it('should return error message', async function () {
const body = await response.json() const body = await response.json()
expect(body.message).to.equal('password is too short') expect(body.message).to.deep.equal({
type: 'error',
key: 'password-too-short',
text: 'Password too short, minimum 8',
})
})
it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
})
describe('new password contains part of email', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
currentPassword: password,
newPassword1: 'somecooluser123',
newPassword2: 'somecooluser123',
}),
})
userHelper = await UserHelper.getUser({ email })
})
it('should return 400', async function () {
expect(response.status).to.equal(400)
})
it('should return error message', async function () {
const body = await response.json()
expect(body.message).to.deep.equal({
key: 'password-contains-email',
type: 'error',
text: 'Password cannot contain parts of email address',
})
}) })
it('should not update audit log', async function () { it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise() const auditLog = userHelper.getAuditLogWithoutNoise()
@ -161,9 +197,11 @@ describe('PasswordUpdate', function () {
}) })
it('should return error message', async function () { it('should return error message', async function () {
const body = await response.json() const body = await response.json()
expect(body.message).to.equal( expect(body.message).to.deep.equal({
'Password is too similar to email address' key: 'password-too-similar',
) type: 'error',
text: 'Password is too similar to parts of email address',
})
}) })
it('should not update audit log', async function () { it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise() const auditLog = userHelper.getAuditLogWithoutNoise()

View file

@ -61,6 +61,9 @@ describe('UserController', function () {
authenticate: sinon.stub(), authenticate: sinon.stub(),
setUserPassword: sinon.stub(), setUserPassword: sinon.stub(),
}, },
getMessageForInvalidPasswordError: sinon
.stub()
.returns({ type: 'error', key: 'some-key' }),
} }
this.UserUpdater = { this.UserUpdater = {
changeEmailAddress: sinon.stub(), changeEmailAddress: sinon.stub(),
@ -771,18 +774,21 @@ describe('UserController', function () {
// .returns({ message: 'validation-error' }) // .returns({ message: 'validation-error' })
const err = new Error('bad') const err = new Error('bad')
err.name = 'InvalidPasswordError' err.name = 'InvalidPasswordError'
const message = {
type: 'error',
key: 'some-message-key',
}
this.AuthenticationManager.getMessageForInvalidPasswordError.returns(
message
)
this.AuthenticationManager.promises.setUserPassword.rejects(err) 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',
newPassword2: 'newpass', newPassword2: 'newpass',
} }
this.HttpErrorHandler.badRequest.callsFake(() => { this.res.json.callsFake(result => {
expect(this.HttpErrorHandler.badRequest).to.have.been.calledWith( expect(result.message).to.deep.equal(message)
this.req,
this.res,
err.message
)
this.AuthenticationManager.promises.setUserPassword.callCount.should.equal( this.AuthenticationManager.promises.setUserPassword.callCount.should.equal(
1 1
) )