diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 308c7731b8..d7a74dd3e1 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -3307,19 +3307,19 @@ describe('afterFind hooks', () => { }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { Parse.Cloud.beforeLogin('SomeClass', () => { }); - }).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + }).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); expect(() => { Parse.Cloud.afterLogin(() => { }); - }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + }).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); expect(() => { Parse.Cloud.afterLogin('_User', () => { }); - }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + }).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); expect(() => { Parse.Cloud.afterLogin(Parse.User, () => { }); - }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + }).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); expect(() => { Parse.Cloud.afterLogin('SomeClass', () => { }); - }).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + }).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); expect(() => { Parse.Cloud.afterLogout(() => { }); }).not.toThrow(); @@ -4656,3 +4656,201 @@ describe('sendEmail', () => { ); }); }); + +describe('beforePasswordResetRequest hook', () => { + it('should run beforePasswordResetRequest with valid user', async done => { + let hit = 0; + let sendPasswordResetEmailCalled = false; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => { + sendPasswordResetEmailCalled = true; + }, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + Parse.Cloud.beforePasswordResetRequest(req => { + hit++; + expect(req.object).toBeDefined(); + expect(req.object.get('email')).toEqual('test@parse.com'); + expect(req.object.get('username')).toEqual('testuser'); + }); + + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password'); + user.set('email', 'test@parse.com'); + await user.signUp(); + + await Parse.User.requestPasswordReset('test@parse.com'); + expect(hit).toBe(1); + expect(sendPasswordResetEmailCalled).toBe(true); + done(); + }); + + it('should be able to block password reset request if an error is thrown', async done => { + let hit = 0; + let sendPasswordResetEmailCalled = false; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => { + sendPasswordResetEmailCalled = true; + }, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + Parse.Cloud.beforePasswordResetRequest(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + + const user = new Parse.User(); + user.setUsername('banneduser'); + user.setPassword('password'); + user.set('email', 'banned@parse.com'); + await user.signUp(); + await user.save({ isBanned: true }); + + try { + await Parse.User.requestPasswordReset('banned@parse.com'); + throw new Error('should not have sent password reset email.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + expect(hit).toBe(1); + expect(sendPasswordResetEmailCalled).toBe(false); + done(); + }); + + it('should be able to block password reset request if an error is thrown even if the user has an attached file', async done => { + let hit = 0; + let sendPasswordResetEmailCalled = false; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => { + sendPasswordResetEmailCalled = true; + }, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + Parse.Cloud.beforePasswordResetRequest(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + + const user = new Parse.User(); + user.setUsername('banneduser2'); + user.setPassword('password'); + user.set('email', 'banned2@parse.com'); + await user.signUp(); + const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE='; + const file = new Parse.File('myfile.txt', { base64 }); + await file.save(); + await user.save({ isBanned: true, file }); + + try { + await Parse.User.requestPasswordReset('banned2@parse.com'); + throw new Error('should not have sent password reset email.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + expect(hit).toBe(1); + expect(sendPasswordResetEmailCalled).toBe(false); + done(); + }); + + it('should not run beforePasswordResetRequest if email does not exist', async done => { + let hit = 0; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + + await reconfigureServer({ + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + Parse.Cloud.beforePasswordResetRequest(req => { + hit++; + }); + + try { + await Parse.User.requestPasswordReset('nonexistent@parse.com'); + } catch (e) { + // May or may not throw depending on passwordPolicy.resetPasswordSuccessOnInvalidEmail + } + expect(hit).toBe(0); + done(); + }); + + it('should have expected data in request in beforePasswordResetRequest', async done => { + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + Parse.Cloud.beforePasswordResetRequest(req => { + expect(req.object).toBeDefined(); + expect(req.object.get('email')).toBeDefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + expect(req.config).toBeDefined(); + }); + + const user = new Parse.User(); + user.setUsername('testuser2'); + user.setPassword('password'); + user.set('email', 'test2@parse.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('test2@parse.com'); + done(); + }); + + it('should validate that only _User class is allowed for beforePasswordResetRequest', () => { + expect(() => { + Parse.Cloud.beforePasswordResetRequest('SomeClass', () => { }); + }).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); + expect(() => { + Parse.Cloud.beforePasswordResetRequest(() => { }); + }).not.toThrow(); + expect(() => { + Parse.Cloud.beforePasswordResetRequest('_User', () => { }); + }).not.toThrow(); + expect(() => { + Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { }); + }).not.toThrow(); + }); +}); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 4f38c60b6c..07df6abee6 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -12,6 +12,7 @@ import { Types as TriggerTypes, getRequestObject, resolveError, + inflate, } from '../triggers'; import { promiseEnsureIdempotency } from '../middlewares'; import RestWrite from '../RestWrite'; @@ -444,21 +445,59 @@ export class UsersRouter extends ClassesRouter { if (!email && !token) { throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email'); } + + let userResults = null; + let userData = null; + + // We can find the user using token if (token) { - const results = await req.config.database.find('_User', { + userResults = await req.config.database.find('_User', { _perishable_token: token, _perishable_token_expires_at: { $lt: Parse._encode(new Date()) }, }); - if (results && results[0] && results[0].email) { - email = results[0].email; + if (userResults && userResults.length > 0) { + userData = userResults[0]; + if (userData.email) { + email = userData.email; + } + } + // Or using email if no token provided + } else if (typeof email === 'string') { + userResults = await req.config.database.find( + '_User', + { $or: [{ email }, { username: email, email: { $exists: false } }] }, + { limit: 1 }, + Auth.maintenance(req.config) + ); + if (userResults && userResults.length > 0) { + userData = userResults[0]; } } + if (typeof email !== 'string') { throw new Parse.Error( Parse.Error.INVALID_EMAIL_ADDRESS, 'you must provide a valid email string' ); } + + if (userData) { + this._sanitizeAuthData(userData); + // Useful to get User attached files in the trigger (photo picture for example) + await req.config.filesController.expandFilesInObject(req.config, userData); + + const user = inflate('_User', userData); + + await maybeRunTrigger( + TriggerTypes.beforePasswordResetRequest, + req.auth, + user, + null, + req.config, + req.info.context + ); + } + const userController = req.config.userController; try { await userController.sendPasswordResetEmail(email); diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index fa982de8f3..45262ec277 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -349,6 +349,49 @@ ParseCloud.afterLogout = function (handler) { triggers.addTrigger(triggers.Types.afterLogout, className, handler, Parse.applicationId); }; +/** + * + * Registers the before password reset request function. + * + * **Available in Cloud Code only.** + * + * This function provides control in validating a password reset request + * before the reset email is sent. It is triggered after the user is found + * by email, but before the reset token is generated and the email is sent. + * + * ``` + * Parse.Cloud.beforePasswordResetRequest((request) => { + * // Validate email or user properties + * if (!request.object.get('emailVerified')) { + * throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'Email not verified'); + * } + * }) + * + * ``` + * + * @method beforePasswordResetRequest + * @name Parse.Cloud.beforePasswordResetRequest + * @param {Function} func The function to run before a password reset request. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.beforePasswordResetRequest = function (handler, validationHandler) { + let className = '_User'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = triggers.getClassName(handler); + handler = arguments[1]; + validationHandler = arguments.length >= 2 ? arguments[2] : null; + } + triggers.addTrigger(triggers.Types.beforePasswordResetRequest, className, handler, Parse.applicationId); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { requestPath: `/requestPasswordReset`, requestMethods: 'POST', ...validationHandler.rateLimit }, + Parse.applicationId, + true + ); + } +}; + /** * Registers an after save function. * diff --git a/src/triggers.js b/src/triggers.js index 26b107f062..e6abf20bb4 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -6,6 +6,7 @@ export const Types = { beforeLogin: 'beforeLogin', afterLogin: 'afterLogin', afterLogout: 'afterLogout', + beforePasswordResetRequest: 'beforePasswordResetRequest', beforeSave: 'beforeSave', afterSave: 'afterSave', beforeDelete: 'beforeDelete', @@ -58,10 +59,10 @@ function validateClassNameForTriggers(className, type) { // TODO: Allow proper documented way of using nested increment ops throw 'Only afterSave is allowed on _PushStatus'; } - if ((type === Types.beforeLogin || type === Types.afterLogin) && className !== '_User') { + if ((type === Types.beforeLogin || type === Types.afterLogin || type === Types.beforePasswordResetRequest) && className !== '_User') { // TODO: check if upstream code will handle `Error` instance rather // than this anti-pattern of throwing strings - throw 'Only the _User class is allowed for the beforeLogin and afterLogin triggers'; + throw 'Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'; } if (type === Types.afterLogout && className !== '_Session') { // TODO: check if upstream code will handle `Error` instance rather @@ -287,6 +288,7 @@ export function getRequestObject( triggerType === Types.afterDelete || triggerType === Types.beforeLogin || triggerType === Types.afterLogin || + triggerType === Types.beforePasswordResetRequest || triggerType === Types.afterFind ) { // Set a copy of the context on the request object.