From e5d610e5e487ddab86409409ac3d7362aba8f59b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 25 Feb 2023 06:30:48 +1100 Subject: [PATCH] feat: Add Parse Server option `resetPasswordSuccessOnInvalidEmail` to choose success or error response on password reset with invalid email (#7551) --- spec/ValidationAndPasswordsReset.spec.js | 39 ++++++++++++++++++++++++ src/Config.js | 7 +++++ src/Options/Definitions.js | 7 +++++ src/Options/docs.js | 1 + src/Options/index.js | 5 +++ src/Routers/UsersRouter.js | 30 +++++++++--------- 6 files changed, 73 insertions(+), 16 deletions(-) diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index a8ae169cf0..3272f07fc3 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -1082,4 +1082,43 @@ describe('Custom Pages, Email Verification, Password Reset', () => { done(); }); }); + + it('should throw on an invalid reset password', async () => { + await reconfigureServer({ + appName: 'coolapp', + publicServerURL: 'http://localhost:1337/1', + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + passwordPolicy: { + resetPasswordSuccessOnInvalidEmail: false, + }, + }); + + await expectAsync(Parse.User.requestPasswordReset('test@example.com')).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'A user with that email does not exist.') + ); + }); + + it('validate resetPasswordSuccessonInvalidEmail', async () => { + const invalidValues = [[], {}, 1, 'string']; + for (const value of invalidValues) { + await expectAsync( + reconfigureServer({ + appName: 'coolapp', + publicServerURL: 'http://localhost:1337/1', + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + passwordPolicy: { + resetPasswordSuccessOnInvalidEmail: value, + }, + }) + ).toBeRejectedWith('resetPasswordSuccessOnInvalidEmail must be a boolean value'); + } + }); }); diff --git a/src/Config.js b/src/Config.js index bd7c6f21af..c993e467fb 100644 --- a/src/Config.js +++ b/src/Config.js @@ -376,6 +376,13 @@ export class Config { if (passwordPolicy.resetTokenReuseIfValid && !passwordPolicy.resetTokenValidityDuration) { throw 'You cannot use resetTokenReuseIfValid without resetTokenValidityDuration'; } + + if ( + passwordPolicy.resetPasswordSuccessOnInvalidEmail && + typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean' + ) { + throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value'; + } } } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index f7a4f822d7..2d53cc7c27 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -907,6 +907,13 @@ module.exports.PasswordPolicyOptions = { 'Set the number of previous password that will not be allowed to be set as new password. If the option is not set or set to `0`, no previous passwords will be considered.

Valid values are >= `0` and <= `20`.
Default is `0`.', action: parsers.numberParser('maxPasswordHistory'), }, + resetPasswordSuccessOnInvalidEmail: { + env: 'PARSE_SERVER_PASSWORD_POLICY_RESET_PASSWORD_SUCCESS_ON_INVALID_EMAIL', + help: + 'Set to `true` if a request to reset the password should return a success response even if the provided email address is invalid, or `false` if the request should return an error response if the email address is invalid.

Default is `true`.', + action: parsers.booleanParser, + default: true, + }, resetTokenReuseIfValid: { env: 'PARSE_SERVER_PASSWORD_POLICY_RESET_TOKEN_REUSE_IF_VALID', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index b0378d327e..0f28270f56 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -207,6 +207,7 @@ * @property {Boolean} doNotAllowUsername Set to `true` to disallow the username as part of the password.

Default is `false`. * @property {Number} maxPasswordAge Set the number of days after which a password expires. Login attempts fail if the user does not reset the password before expiration. * @property {Number} maxPasswordHistory Set the number of previous password that will not be allowed to be set as new password. If the option is not set or set to `0`, no previous passwords will be considered.

Valid values are >= `0` and <= `20`.
Default is `0`. + * @property {Boolean} resetPasswordSuccessOnInvalidEmail Set to `true` if a request to reset the password should return a success response even if the provided email address is invalid, or `false` if the request should return an error response if the email address is invalid.

Default is `true`. * @property {Boolean} resetTokenReuseIfValid Set to `true` if a password reset token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`. * @property {Number} resetTokenValidityDuration Set the validity duration of the password reset token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`. * @property {String} validationError Set the error message to be sent.

Default is `Password does not meet the Password Policy requirements.` diff --git a/src/Options/index.js b/src/Options/index.js index 661d062de6..6d0f488b88 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -525,6 +525,11 @@ export interface PasswordPolicyOptions { Default is `false`. :DEFAULT: false */ resetTokenReuseIfValid: ?boolean; + /* Set to `true` if a request to reset the password should return a success response even if the provided email address is invalid, or `false` if the request should return an error response if the email address is invalid. +

+ Default is `true`. + :DEFAULT: true */ + resetPasswordSuccessOnInvalidEmail: ?boolean; } export interface FileUploadOptions { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index a0c0039c47..4a72fdd73b 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -414,7 +414,7 @@ export class UsersRouter extends ClassesRouter { } } - handleResetRequest(req) { + async handleResetRequest(req) { this._throwOnBadEmailConfig(req); const { email } = req.body; @@ -428,24 +428,22 @@ export class UsersRouter extends ClassesRouter { ); } const userController = req.config.userController; - return userController.sendPasswordResetEmail(email).then( - () => { - return Promise.resolve({ - response: {}, - }); - }, - err => { - if (err.code === Parse.Error.OBJECT_NOT_FOUND) { - // Return success so that this endpoint can't - // be used to enumerate valid emails - return Promise.resolve({ + try { + await userController.sendPasswordResetEmail(email); + return { + response: {}, + }; + } catch (err) { + if (err.code === Parse.Error.OBJECT_NOT_FOUND) { + if (req.config.passwordPolicy?.resetPasswordSuccessOnInvalidEmail ?? true) { + return { response: {}, - }); - } else { - throw err; + }; } + err.message = `A user with that email does not exist.`; } - ); + throw err; + } } handleVerificationEmailRequest(req) {