mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
11af938486
commit
9d00c351a8
8 changed files with 67 additions and 62 deletions
|
@ -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']
|
||||||
|
|
|
@ -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'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
||||||
|
|
|
@ -558,6 +558,7 @@ module.exports = {
|
||||||
disabled: {
|
disabled: {
|
||||||
invite: true,
|
invite: true,
|
||||||
login: true,
|
login: true,
|
||||||
|
passwordReset: true,
|
||||||
register: true,
|
register: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue