Merge pull request #4327 from overleaf/jpa-pw-reset-captcha

[misc] add captcha on password reset requests

GitOrigin-RevId: 9a23b9c9dee2c56345e9c1846861c05c25126802
This commit is contained in:
Jakob Ackermann 2021-07-21 13:52:24 +02:00 committed by Copybot
parent 11af938486
commit 9d00c351a8
8 changed files with 67 additions and 62 deletions

View file

@ -24,11 +24,7 @@ module.exports = CaptchaMiddleware = {
) { ) {
return next() return next()
} }
const inviteAndCaptchaDisabled = if (Settings.recaptcha.disabled[action]) {
action === 'invite' && Settings.recaptcha.disabled.invite
const registerAndCaptchaDisabled =
action === 'register' && Settings.recaptcha.disabled.register
if (inviteAndCaptchaDisabled || registerAndCaptchaDisabled) {
return next() return next()
} }
const response = req.body['g-recaptcha-response'] const response = req.body['g-recaptcha-response']

View file

@ -1,5 +1,4 @@
const PasswordResetHandler = require('./PasswordResetHandler') const PasswordResetHandler = require('./PasswordResetHandler')
const RateLimiter = require('../../infrastructure/RateLimiter')
const AuthenticationController = require('../Authentication/AuthenticationController') const AuthenticationController = require('../Authentication/AuthenticationController')
const UserGetter = require('../User/UserGetter') const UserGetter = require('../User/UserGetter')
const UserUpdater = require('../User/UserUpdater') const UserUpdater = require('../User/UserUpdater')
@ -61,44 +60,31 @@ module.exports = {
}, },
requestReset(req, res, next) { requestReset(req, res, next) {
const email = req.body.email.trim().toLowerCase() const email = EmailsHelper.parseEmail(req.body.email)
const opts = { if (!email) {
endpointName: 'password_reset_rate_limit', return res.status(400).send({
timeInterval: 60, message: req.i18n.translate('must_be_email_address'),
subjectName: req.ip, })
throttle: 6,
} }
RateLimiter.addCount(opts, (err, canContinue) => { PasswordResetHandler.generateAndEmailResetToken(email, (err, status) => {
if (err != null) { if (err != null) {
return next( OError.tag(err, 'failed to generate and email password reset token', {
new OError('rate-limit password reset failed').withCause(err) email,
) })
} next(err)
if (!canContinue) { } else if (status === 'primary') {
return res.status(429).send({ res.status(200).send({
message: req.i18n.translate('rate_limit_hit_wait'), message: { text: req.i18n.translate('password_reset_email_sent') },
})
} else if (status === 'secondary') {
res.status(404).send({
message: req.i18n.translate('secondary_email_password_reset'),
})
} else {
res.status(404).send({
message: req.i18n.translate('cant_find_email'),
}) })
} }
PasswordResetHandler.generateAndEmailResetToken(email, (err, status) => {
if (err != null) {
OError.tag(err, 'failed to generate and email password reset token', {
email,
})
next(err)
} else if (status === 'primary') {
res.status(200).send({
message: { text: req.i18n.translate('password_reset_email_sent') },
})
} else if (status === 'secondary') {
res.status(404).send({
message: req.i18n.translate('secondary_email_password_reset'),
})
} else {
res.status(404).send({
message: req.i18n.translate('cant_find_email'),
})
}
})
}) })
}, },

View file

@ -1,9 +1,18 @@
const PasswordResetController = require('./PasswordResetController') const PasswordResetController = require('./PasswordResetController')
const AuthenticationController = require('../Authentication/AuthenticationController') const AuthenticationController = require('../Authentication/AuthenticationController')
const CaptchaMiddleware = require('../../Features/Captcha/CaptchaMiddleware')
const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware')
const { Joi, validate } = require('../../infrastructure/Validation') const { Joi, validate } = require('../../infrastructure/Validation')
module.exports = { module.exports = {
apply(webRouter, apiRouter) { apply(webRouter, apiRouter) {
const rateLimit = RateLimiterMiddleware.rateLimit({
endpointName: 'password_reset_rate_limit',
ipOnly: true,
maxRequests: 6,
timeInterval: 60,
})
webRouter.get( webRouter.get(
'/user/password/reset', '/user/password/reset',
PasswordResetController.renderRequestResetForm PasswordResetController.renderRequestResetForm
@ -15,6 +24,8 @@ module.exports = {
email: Joi.string().required(), email: Joi.string().required(),
}), }),
}), }),
rateLimit,
CaptchaMiddleware.validateCaptcha('passwordReset'),
PasswordResetController.requestReset PasswordResetController.requestReset
) )
AuthenticationController.addEndpointToLoginWhitelist('/user/password/reset') AuthenticationController.addEndpointToLoginWhitelist('/user/password/reset')
@ -31,6 +42,7 @@ module.exports = {
passwordResetToken: Joi.string().required(), passwordResetToken: Joi.string().required(),
}), }),
}), }),
rateLimit,
PasswordResetController.setNewUserPassword PasswordResetController.setNewUserPassword
) )
AuthenticationController.addEndpointToLoginWhitelist('/user/password/set') AuthenticationController.addEndpointToLoginWhitelist('/user/password/set')
@ -42,6 +54,8 @@ module.exports = {
email: Joi.string().required(), email: Joi.string().required(),
}), }),
}), }),
rateLimit,
CaptchaMiddleware.validateCaptcha('passwordReset'),
PasswordResetController.requestReset PasswordResetController.requestReset
) )
}, },

View file

@ -4,6 +4,18 @@ block vars
- metadata = { viewport: true } - metadata = { viewport: true }
block content block content
- var showCaptcha = settings.recaptcha && settings.recaptcha.siteKey && !(settings.recaptcha.disabled && settings.recaptcha.disabled.passwordReset)
if showCaptcha
script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render=explicit")
div(
id="recaptcha"
class="g-recaptcha"
data-sitekey=settings.recaptcha.siteKey
data-size="invisible"
data-badge="inline"
)
main.content.content-alt main.content.content-alt
.container .container
.row .row
@ -17,6 +29,8 @@ block content
name="passwordResetForm" name="passwordResetForm"
action="/user/password/reset", action="/user/password/reset",
method="POST", method="POST",
captcha=(showCaptcha ? '' : false),
captcha-action-name=(showCaptcha ? "passwordReset" : false),
ng-cloak ng-cloak
) )
input(type="hidden", name="_csrf", value=csrfToken) input(type="hidden", name="_csrf", value=csrfToken)

View file

@ -2,6 +2,18 @@ extends ../layout
block content block content
- var email = reconfirm_email ? reconfirm_email : "" - var email = reconfirm_email ? reconfirm_email : ""
- var showCaptcha = settings.recaptcha && settings.recaptcha.siteKey && !(settings.recaptcha.disabled && settings.recaptcha.disabled.passwordReset)
if showCaptcha
script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render=explicit")
div(
id="recaptcha"
class="g-recaptcha"
data-sitekey=settings.recaptcha.siteKey
data-size="invisible"
data-badge="inline"
)
main.content.content-alt main.content.content-alt
.container .container
.row .row
@ -19,6 +31,8 @@ block content
ng-cloak ng-cloak
ng-init="email='"+email+"'" ng-init="email='"+email+"'"
aria-label=translate('request_reconfirmation_email') aria-label=translate('request_reconfirmation_email')
captcha=(showCaptcha ? '' : false),
captcha-action-name=(showCaptcha ? "passwordReset" : false),
) )
input(type="hidden", name="_csrf", value=csrfToken) input(type="hidden", name="_csrf", value=csrfToken)
form-messages(for="reconfirmAccountForm" role="alert") form-messages(for="reconfirmAccountForm" role="alert")

View file

@ -32,6 +32,8 @@ block content
| Request a new password reset email | Request a new password reset email
.alert.alert-danger(ng-switch-when="400") .alert.alert-danger(ng-switch-when="400")
| #{translate('invalid_password')} | #{translate('invalid_password')}
.alert.alert-danger(ng-switch-when="429")
| #{translate('rate_limit_hit_wait')}
.alert.alert-danger(ng-switch-default) .alert.alert-danger(ng-switch-default)
| #{translate('error_performing_request')} | #{translate('error_performing_request')}

View file

@ -558,6 +558,7 @@ module.exports = {
disabled: { disabled: {
invite: true, invite: true,
login: true, login: true,
passwordReset: true,
register: true, register: true,
}, },
}, },

View file

@ -38,7 +38,6 @@ describe('PasswordResetController', function () {
.resolves({ found: true, reset: true, userID: this.user_id }), .resolves({ found: true, reset: true, userID: this.user_id }),
}, },
} }
this.RateLimiter = { addCount: sinon.stub() }
this.UserSessionsManager = { this.UserSessionsManager = {
promises: { promises: {
revokeAllUserSessions: sinon.stub().resolves(), revokeAllUserSessions: sinon.stub().resolves(),
@ -53,7 +52,6 @@ describe('PasswordResetController', function () {
requires: { requires: {
'@overleaf/settings': this.settings, '@overleaf/settings': this.settings,
'./PasswordResetHandler': this.PasswordResetHandler, './PasswordResetHandler': this.PasswordResetHandler,
'../../infrastructure/RateLimiter': this.RateLimiter,
'../Authentication/AuthenticationController': (this.AuthenticationController = { '../Authentication/AuthenticationController': (this.AuthenticationController = {
getLoggedInUserId: sinon.stub(), getLoggedInUserId: sinon.stub(),
finishLogin: sinon.stub(), finishLogin: sinon.stub(),
@ -70,23 +68,7 @@ describe('PasswordResetController', function () {
}) })
describe('requestReset', function () { describe('requestReset', function () {
it('should error if the rate limit is hit', function (done) {
this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith(
1,
null,
'primary'
)
this.RateLimiter.addCount.callsArgWith(1, null, false)
this.PasswordResetController.requestReset(this.req, this.res)
this.PasswordResetHandler.generateAndEmailResetToken
.calledWith(this.email)
.should.equal(false)
this.res.statusCode.should.equal(429)
done()
})
it('should tell the handler to process that email', function (done) { it('should tell the handler to process that email', function (done) {
this.RateLimiter.addCount.callsArgWith(1, null, true)
this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith( this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith(
1, 1,
null, null,
@ -101,7 +83,6 @@ describe('PasswordResetController', function () {
}) })
it('should send a 500 if there is an error', function (done) { it('should send a 500 if there is an error', function (done) {
this.RateLimiter.addCount.callsArgWith(1, null, true)
this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith( this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith(
1, 1,
new Error('error') new Error('error')
@ -113,7 +94,6 @@ describe('PasswordResetController', function () {
}) })
it("should send a 404 if the email doesn't exist", function (done) { it("should send a 404 if the email doesn't exist", function (done) {
this.RateLimiter.addCount.callsArgWith(1, null, true)
this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith( this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith(
1, 1,
null, null,
@ -125,7 +105,6 @@ describe('PasswordResetController', function () {
}) })
it('should send a 404 if the email is registered as a secondard email', function (done) { it('should send a 404 if the email is registered as a secondard email', function (done) {
this.RateLimiter.addCount.callsArgWith(1, null, true)
this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith( this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith(
1, 1,
null, null,
@ -139,7 +118,6 @@ describe('PasswordResetController', function () {
it('should normalize the email address', function (done) { it('should normalize the email address', function (done) {
this.email = ' UPperCaseEMAILWithSpacesAround@example.Com ' this.email = ' UPperCaseEMAILWithSpacesAround@example.Com '
this.req.body.email = this.email this.req.body.email = this.email
this.RateLimiter.addCount.callsArgWith(1, null, true)
this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith( this.PasswordResetHandler.generateAndEmailResetToken.callsArgWith(
1, 1,
null, null,