Skip to content

Commit 62dd3c5

Browse files
authored
fix: Indexes _email_verify_token for email verification and _perishable_token password reset are not created automatically (#9893)
1 parent 00f8d4c commit 62dd3c5

File tree

6 files changed

+192
-1
lines changed

6 files changed

+192
-1
lines changed

8.0.0.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This document only highlights specific changes that require a longer explanation
55
---
66

77
- [Email Verification](#email-verification)
8+
- [Database Indexes](#database-indexes)
89

910
---
1011

@@ -22,6 +23,22 @@ The request to re-send a verification email changed to sending a `POST` request
2223
> [!IMPORTANT]
2324
> Parse Server does not keep a history of verification tokens but only stores the most recently generated verification token in the database. Every time Parse Server generates a new verification token, the currently stored token is replaced. If a user opens a link with an expired token, and that token has already been replaced in the database, Parse Server cannot associate the expired token with any user. In this case, another way has to be offered to the user to re-send a verification email. To mitigate this issue, set the Parse Server option `emailVerifyTokenReuseIfValid: true` and set `emailVerifyTokenValidityDuration` to a longer duration, which ensures that the currently stored verification token is not replaced too soon.
2425
25-
Related pull requests:
26+
Related pull request:
2627

2728
- https://github.com/parse-community/parse-server/pull/8488
29+
30+
## Database Indexes
31+
32+
As part of the email verification and password reset improvements in Parse Server 8, the queries used for these operations have changed to use tokens instead of username/email fields. To ensure optimal query performance, Parse Server now automatically creates indexes on the following fields during server initialization:
33+
34+
- `_User._email_verify_token`: used for email verification queries
35+
- `_User._perishable_token`: used for password reset queries
36+
37+
These indexes are created automatically when Parse Server starts, similar to how indexes for `username` and `email` fields are created. No manual intervention is required.
38+
39+
> [!WARNING]
40+
> If you have a large existing user base, the index creation may take some time during the first server startup after upgrading to Parse Server 8. The server logs will indicate when index creation is complete or if any errors occur. If you have any concerns regarding a potential database performance impact during index creation, you could create these indexes manually in a controlled procedure before upgrading Parse Server.
41+
42+
Related pull request:
43+
44+
- https://github.com/parse-community/parse-server/pull/9893

spec/DatabaseController.spec.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,8 @@ describe('DatabaseController', function () {
413413
case_insensitive_username: { username: 1 },
414414
case_insensitive_email: { email: 1 },
415415
email_1: { email: 1 },
416+
_email_verify_token: { _email_verify_token: 1 },
417+
_perishable_token: { _perishable_token: 1 },
416418
});
417419
}
418420
);
@@ -437,9 +439,153 @@ describe('DatabaseController', function () {
437439
_id_: { _id: 1 },
438440
username_1: { username: 1 },
439441
email_1: { email: 1 },
442+
_email_verify_token: { _email_verify_token: 1 },
443+
_perishable_token: { _perishable_token: 1 },
440444
});
441445
}
442446
);
447+
448+
it_only_db('mongo')(
449+
'should use _email_verify_token index in email verification',
450+
async () => {
451+
const TestUtils = require('../lib/TestUtils');
452+
let emailVerificationLink;
453+
const emailSentPromise = TestUtils.resolvingPromise();
454+
const emailAdapter = {
455+
sendVerificationEmail: options => {
456+
emailVerificationLink = options.link;
457+
emailSentPromise.resolve();
458+
},
459+
sendPasswordResetEmail: () => Promise.resolve(),
460+
sendMail: () => {},
461+
};
462+
await reconfigureServer({
463+
databaseURI: 'mongodb://localhost:27017/testEmailVerifyTokenIndexStats',
464+
databaseAdapter: undefined,
465+
appName: 'test',
466+
verifyUserEmails: true,
467+
emailAdapter: emailAdapter,
468+
publicServerURL: 'http://localhost:8378/1',
469+
});
470+
471+
// Create a user to trigger email verification
472+
const user = new Parse.User();
473+
user.setUsername('statsuser');
474+
user.setPassword('password');
475+
user.set('email', 'stats@example.com');
476+
await user.signUp();
477+
await emailSentPromise;
478+
479+
// Get index stats before the query
480+
const config = Config.get(Parse.applicationId);
481+
const collection = await config.database.adapter._adaptiveCollection('_User');
482+
const statsBefore = await collection._mongoCollection.aggregate([
483+
{ $indexStats: {} },
484+
]).toArray();
485+
const emailVerifyIndexBefore = statsBefore.find(
486+
stat => stat.name === '_email_verify_token'
487+
);
488+
const accessesBefore = emailVerifyIndexBefore?.accesses?.ops || 0;
489+
490+
// Perform email verification (this should use the index)
491+
const request = require('../lib/request');
492+
await request({
493+
url: emailVerificationLink,
494+
followRedirects: false,
495+
});
496+
497+
// Get index stats after the query
498+
const statsAfter = await collection._mongoCollection.aggregate([
499+
{ $indexStats: {} },
500+
]).toArray();
501+
const emailVerifyIndexAfter = statsAfter.find(
502+
stat => stat.name === '_email_verify_token'
503+
);
504+
const accessesAfter = emailVerifyIndexAfter?.accesses?.ops || 0;
505+
506+
// Verify the index was actually used
507+
expect(accessesAfter).toBeGreaterThan(accessesBefore);
508+
expect(emailVerifyIndexAfter).toBeDefined();
509+
510+
// Verify email verification succeeded
511+
await user.fetch();
512+
expect(user.get('emailVerified')).toBe(true);
513+
}
514+
);
515+
516+
it_only_db('mongo')(
517+
'should use _perishable_token index in password reset',
518+
async () => {
519+
const TestUtils = require('../lib/TestUtils');
520+
let passwordResetLink;
521+
const emailSentPromise = TestUtils.resolvingPromise();
522+
const emailAdapter = {
523+
sendVerificationEmail: () => Promise.resolve(),
524+
sendPasswordResetEmail: options => {
525+
passwordResetLink = options.link;
526+
emailSentPromise.resolve();
527+
},
528+
sendMail: () => {},
529+
};
530+
await reconfigureServer({
531+
databaseURI: 'mongodb://localhost:27017/testPerishableTokenIndexStats',
532+
databaseAdapter: undefined,
533+
appName: 'test',
534+
emailAdapter: emailAdapter,
535+
publicServerURL: 'http://localhost:8378/1',
536+
});
537+
538+
// Create a user
539+
const user = new Parse.User();
540+
user.setUsername('statsuser2');
541+
user.setPassword('oldpassword');
542+
user.set('email', 'stats2@example.com');
543+
await user.signUp();
544+
545+
// Request password reset
546+
await Parse.User.requestPasswordReset('stats2@example.com');
547+
await emailSentPromise;
548+
549+
const url = new URL(passwordResetLink);
550+
const token = url.searchParams.get('token');
551+
552+
// Get index stats before the query
553+
const config = Config.get(Parse.applicationId);
554+
const collection = await config.database.adapter._adaptiveCollection('_User');
555+
const statsBefore = await collection._mongoCollection.aggregate([
556+
{ $indexStats: {} },
557+
]).toArray();
558+
const perishableTokenIndexBefore = statsBefore.find(
559+
stat => stat.name === '_perishable_token'
560+
);
561+
const accessesBefore = perishableTokenIndexBefore?.accesses?.ops || 0;
562+
563+
// Perform password reset (this should use the index)
564+
const request = require('../lib/request');
565+
await request({
566+
method: 'POST',
567+
url: 'http://localhost:8378/1/apps/test/request_password_reset',
568+
body: { new_password: 'newpassword', token, username: 'statsuser2' },
569+
headers: {
570+
'Content-Type': 'application/x-www-form-urlencoded',
571+
},
572+
followRedirects: false,
573+
});
574+
575+
// Get index stats after the query
576+
const statsAfter = await collection._mongoCollection.aggregate([
577+
{ $indexStats: {} },
578+
]).toArray();
579+
const perishableTokenIndexAfter = statsAfter.find(
580+
stat => stat.name === '_perishable_token'
581+
);
582+
const accessesAfter = perishableTokenIndexAfter?.accesses?.ops || 0;
583+
584+
// Verify the index was actually used
585+
expect(accessesAfter).toBeGreaterThan(accessesBefore);
586+
expect(perishableTokenIndexAfter).toBeDefined();
587+
}
588+
);
443589
});
444590

445591
describe('convertEmailToLowercase', () => {

spec/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module.exports = [
2525
it_id: "readonly",
2626
fit_id: "readonly",
2727
it_only_db: "readonly",
28+
fit_only_db: "readonly",
2829
it_only_mongodb_version: "readonly",
2930
it_only_postgres_version: "readonly",
3031
it_only_node_version: "readonly",

spec/helper.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,17 @@ global.it_only_db = db => {
515515
}
516516
};
517517

518+
global.fit_only_db = db => {
519+
if (
520+
process.env.PARSE_SERVER_TEST_DB === db ||
521+
(!process.env.PARSE_SERVER_TEST_DB && db == 'mongo')
522+
) {
523+
return fit;
524+
} else {
525+
return xit;
526+
}
527+
};
528+
518529
global.it_only_mongodb_version = version => {
519530
if (!semver.validRange(version)) {
520531
throw new Error('Invalid version range');

src/Adapters/Storage/Mongo/MongoStorageAdapter.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ export class MongoStorageAdapter implements StorageAdapter {
687687
const defaultOptions: Object = { background: true, sparse: true };
688688
const indexNameOptions: Object = indexName ? { name: indexName } : {};
689689
const ttlOptions: Object = options.ttl !== undefined ? { expireAfterSeconds: options.ttl } : {};
690+
const sparseOptions: Object = options.sparse !== undefined ? { sparse: options.sparse } : {};
690691
const caseInsensitiveOptions: Object = caseInsensitive
691692
? { collation: MongoCollection.caseInsensitiveCollation() }
692693
: {};
@@ -695,6 +696,7 @@ export class MongoStorageAdapter implements StorageAdapter {
695696
...caseInsensitiveOptions,
696697
...indexNameOptions,
697698
...ttlOptions,
699+
...sparseOptions,
698700
};
699701

700702
return this._adaptiveCollection(className)

src/Controllers/DatabaseController.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,20 @@ class DatabaseController {
17641764
throw error;
17651765
});
17661766

1767+
await this.adapter
1768+
.ensureIndex('_User', requiredUserFields, ['_email_verify_token'], '_email_verify_token', false)
1769+
.catch(error => {
1770+
logger.warn('Unable to create index for email verification token: ', error);
1771+
throw error;
1772+
});
1773+
1774+
await this.adapter
1775+
.ensureIndex('_User', requiredUserFields, ['_perishable_token'], '_perishable_token', false)
1776+
.catch(error => {
1777+
logger.warn('Unable to create index for password reset token: ', error);
1778+
throw error;
1779+
});
1780+
17671781
await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => {
17681782
logger.warn('Unable to ensure uniqueness for role name: ', error);
17691783
throw error;

0 commit comments

Comments
 (0)