Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c354da3
feat: add beforePasswordResetRequest hook
coratgerl Nov 6, 2025
e30572d
fix: feedbacks
coratgerl Nov 7, 2025
2971acf
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 7, 2025
8076a65
fix: test placement missing
coratgerl Nov 8, 2025
659ab5e
fix: test format
coratgerl Nov 8, 2025
fce3723
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 8, 2025
2045d87
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 8, 2025
8bfc13b
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 8, 2025
39f0e47
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 9, 2025
73aa8dd
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 9, 2025
0b3b21d
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 9, 2025
57712ce
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 9, 2025
903c00f
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 9, 2025
10acf6a
Merge branch 'alpha' into before-password-reset-request
coratgerl Nov 17, 2025
2cccdc4
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 17, 2025
6dee8bf
fix: feedbacks
coratgerl Nov 17, 2025
39a6a0b
Merge branch 'alpha' into before-password-reset-request
coratgerl Nov 17, 2025
ed0af6a
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 17, 2025
7d17d09
fix: feedbacks
coratgerl Nov 17, 2025
4316248
fix: feedbacks
coratgerl Nov 18, 2025
6d5c94d
Merge branch 'alpha' into before-password-reset-request
mtrezza Nov 18, 2025
08654bd
Update UsersRouter.js
mtrezza Nov 18, 2025
2a6df76
Update UsersRouter.js
mtrezza Nov 18, 2025
8917926
fix: merge tests
coratgerl Nov 18, 2025
7d436c9
docs
mtrezza Nov 19, 2025
a1dc176
docs
mtrezza Nov 19, 2025
a065b7a
docs
mtrezza Nov 19, 2025
6418ad1
feat: add comment on test
coratgerl Nov 19, 2025
e5c8fb9
fix: message
coratgerl Nov 19, 2025
4d5f57b
fix: complete test
coratgerl Nov 19, 2025
ad8711c
fix: remove comment
coratgerl Nov 19, 2025
e5b3e09
fix: merge save
coratgerl Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 159 additions & 5 deletions spec/CloudCode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -4656,3 +4656,157 @@ describe('sendEmail', () => {
);
});
});

describe('beforePasswordResetRequest hook', () => {
it('should run beforePasswordResetRequest with valid user', async () => {
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('[email protected]');
expect(req.object.get('username')).toEqual('testuser');
});

const user = new Parse.User();
user.setUsername('testuser');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();

await Parse.User.requestPasswordReset('[email protected]');
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(true);
});

it('should be able to block password reset request if an error is thrown', async () => {
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++;
throw new Error('password reset blocked');
});

const user = new Parse.User();
user.setUsername('testuser');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();

try {
await Parse.User.requestPasswordReset('[email protected]');
throw new Error('should not have sent password reset email.');
} catch (e) {
expect(e.message).toBe('password reset blocked');
}
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(false);
});

it('should not run beforePasswordResetRequest if email does not exist', async () => {
let hit = 0;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {},
sendMail: () => {},
};

await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
});

await Parse.User.requestPasswordReset('[email protected]');

expect(hit).toBe(0);
});

it('should have expected data in request in beforePasswordResetRequest', async () => {
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {},
sendMail: () => {},
};

await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=';
const file = new Parse.File('myfile.txt', { base64 });
await file.save();

Parse.Cloud.beforePasswordResetRequest(req => {
expect(req.object).toBeDefined();
expect(req.object.get('email')).toBeDefined();
expect(req.object.get('email')).toBe('[email protected]');
expect(req.object.get('file')).toBeDefined();
expect(req.object.get('file')).toBeInstanceOf(Parse.File);
expect(req.object.get('file').name()).toContain('myfile.txt');
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', '[email protected]');
user.set('file', file);
await user.signUp();

await Parse.User.requestPasswordReset('[email protected]');
});

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();
});
});
45 changes: 42 additions & 3 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Types as TriggerTypes,
getRequestObject,
resolveError,
inflate,
} from '../triggers';
import { promiseEnsureIdempotency } from '../middlewares';
import RestWrite from '../RestWrite';
Expand Down Expand Up @@ -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?.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?.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);
// Get files attached to user
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);
Expand Down
42 changes: 42 additions & 0 deletions src/cloud-code/Parse.Cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,48 @@ 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.
*
* Code example:
*
* ```
* Parse.Cloud.beforePasswordResetRequest(request => {
* if (request.object.get('banned')) {
* throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User is banned.');
* }
* });
* ```
*
* @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.
*
Expand Down
6 changes: 4 additions & 2 deletions src/triggers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const Types = {
beforeLogin: 'beforeLogin',
afterLogin: 'afterLogin',
afterLogout: 'afterLogout',
beforePasswordResetRequest: 'beforePasswordResetRequest',
beforeSave: 'beforeSave',
afterSave: 'afterSave',
beforeDelete: 'beforeDelete',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading