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 a8ae169c..3272f07f 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 bd7c6f21..c993e467 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 f7a4f822..2d53cc7c 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 b0378d32..0f28270f 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 661d062d..6d0f488b 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 a0c0039c..4a72fdd7 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) {