fix: Indexes _email_verify_token for email verification and _perishable_token password reset are not created automatically (#9893)

This commit is contained in:
Manuel
2025-11-01 13:52:23 +01:00
committed by GitHub
parent 00f8d4cda9
commit 62dd3c565a
6 changed files with 192 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ This document only highlights specific changes that require a longer explanation
---
- [Email Verification](#email-verification)
- [Database Indexes](#database-indexes)
---
@@ -22,6 +23,22 @@ The request to re-send a verification email changed to sending a `POST` request
> [!IMPORTANT]
> 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.
Related pull requests:
Related pull request:
- https://github.com/parse-community/parse-server/pull/8488
## Database Indexes
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:
- `_User._email_verify_token`: used for email verification queries
- `_User._perishable_token`: used for password reset queries
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.
> [!WARNING]
> 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.
Related pull request:
- https://github.com/parse-community/parse-server/pull/9893

View File

@@ -413,6 +413,8 @@ describe('DatabaseController', function () {
case_insensitive_username: { username: 1 },
case_insensitive_email: { email: 1 },
email_1: { email: 1 },
_email_verify_token: { _email_verify_token: 1 },
_perishable_token: { _perishable_token: 1 },
});
}
);
@@ -437,9 +439,153 @@ describe('DatabaseController', function () {
_id_: { _id: 1 },
username_1: { username: 1 },
email_1: { email: 1 },
_email_verify_token: { _email_verify_token: 1 },
_perishable_token: { _perishable_token: 1 },
});
}
);
it_only_db('mongo')(
'should use _email_verify_token index in email verification',
async () => {
const TestUtils = require('../lib/TestUtils');
let emailVerificationLink;
const emailSentPromise = TestUtils.resolvingPromise();
const emailAdapter = {
sendVerificationEmail: options => {
emailVerificationLink = options.link;
emailSentPromise.resolve();
},
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
};
await reconfigureServer({
databaseURI: 'mongodb://localhost:27017/testEmailVerifyTokenIndexStats',
databaseAdapter: undefined,
appName: 'test',
verifyUserEmails: true,
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});
// Create a user to trigger email verification
const user = new Parse.User();
user.setUsername('statsuser');
user.setPassword('password');
user.set('email', 'stats@example.com');
await user.signUp();
await emailSentPromise;
// Get index stats before the query
const config = Config.get(Parse.applicationId);
const collection = await config.database.adapter._adaptiveCollection('_User');
const statsBefore = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const emailVerifyIndexBefore = statsBefore.find(
stat => stat.name === '_email_verify_token'
);
const accessesBefore = emailVerifyIndexBefore?.accesses?.ops || 0;
// Perform email verification (this should use the index)
const request = require('../lib/request');
await request({
url: emailVerificationLink,
followRedirects: false,
});
// Get index stats after the query
const statsAfter = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const emailVerifyIndexAfter = statsAfter.find(
stat => stat.name === '_email_verify_token'
);
const accessesAfter = emailVerifyIndexAfter?.accesses?.ops || 0;
// Verify the index was actually used
expect(accessesAfter).toBeGreaterThan(accessesBefore);
expect(emailVerifyIndexAfter).toBeDefined();
// Verify email verification succeeded
await user.fetch();
expect(user.get('emailVerified')).toBe(true);
}
);
it_only_db('mongo')(
'should use _perishable_token index in password reset',
async () => {
const TestUtils = require('../lib/TestUtils');
let passwordResetLink;
const emailSentPromise = TestUtils.resolvingPromise();
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
passwordResetLink = options.link;
emailSentPromise.resolve();
},
sendMail: () => {},
};
await reconfigureServer({
databaseURI: 'mongodb://localhost:27017/testPerishableTokenIndexStats',
databaseAdapter: undefined,
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});
// Create a user
const user = new Parse.User();
user.setUsername('statsuser2');
user.setPassword('oldpassword');
user.set('email', 'stats2@example.com');
await user.signUp();
// Request password reset
await Parse.User.requestPasswordReset('stats2@example.com');
await emailSentPromise;
const url = new URL(passwordResetLink);
const token = url.searchParams.get('token');
// Get index stats before the query
const config = Config.get(Parse.applicationId);
const collection = await config.database.adapter._adaptiveCollection('_User');
const statsBefore = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const perishableTokenIndexBefore = statsBefore.find(
stat => stat.name === '_perishable_token'
);
const accessesBefore = perishableTokenIndexBefore?.accesses?.ops || 0;
// Perform password reset (this should use the index)
const request = require('../lib/request');
await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/request_password_reset',
body: { new_password: 'newpassword', token, username: 'statsuser2' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
followRedirects: false,
});
// Get index stats after the query
const statsAfter = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const perishableTokenIndexAfter = statsAfter.find(
stat => stat.name === '_perishable_token'
);
const accessesAfter = perishableTokenIndexAfter?.accesses?.ops || 0;
// Verify the index was actually used
expect(accessesAfter).toBeGreaterThan(accessesBefore);
expect(perishableTokenIndexAfter).toBeDefined();
}
);
});
describe('convertEmailToLowercase', () => {

View File

@@ -25,6 +25,7 @@ module.exports = [
it_id: "readonly",
fit_id: "readonly",
it_only_db: "readonly",
fit_only_db: "readonly",
it_only_mongodb_version: "readonly",
it_only_postgres_version: "readonly",
it_only_node_version: "readonly",

View File

@@ -515,6 +515,17 @@ global.it_only_db = db => {
}
};
global.fit_only_db = db => {
if (
process.env.PARSE_SERVER_TEST_DB === db ||
(!process.env.PARSE_SERVER_TEST_DB && db == 'mongo')
) {
return fit;
} else {
return xit;
}
};
global.it_only_mongodb_version = version => {
if (!semver.validRange(version)) {
throw new Error('Invalid version range');

View File

@@ -687,6 +687,7 @@ export class MongoStorageAdapter implements StorageAdapter {
const defaultOptions: Object = { background: true, sparse: true };
const indexNameOptions: Object = indexName ? { name: indexName } : {};
const ttlOptions: Object = options.ttl !== undefined ? { expireAfterSeconds: options.ttl } : {};
const sparseOptions: Object = options.sparse !== undefined ? { sparse: options.sparse } : {};
const caseInsensitiveOptions: Object = caseInsensitive
? { collation: MongoCollection.caseInsensitiveCollation() }
: {};
@@ -695,6 +696,7 @@ export class MongoStorageAdapter implements StorageAdapter {
...caseInsensitiveOptions,
...indexNameOptions,
...ttlOptions,
...sparseOptions,
};
return this._adaptiveCollection(className)

View File

@@ -1764,6 +1764,20 @@ class DatabaseController {
throw error;
});
await this.adapter
.ensureIndex('_User', requiredUserFields, ['_email_verify_token'], '_email_verify_token', false)
.catch(error => {
logger.warn('Unable to create index for email verification token: ', error);
throw error;
});
await this.adapter
.ensureIndex('_User', requiredUserFields, ['_perishable_token'], '_perishable_token', false)
.catch(error => {
logger.warn('Unable to create index for password reset token: ', error);
throw error;
});
await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => {
logger.warn('Unable to ensure uniqueness for role name: ', error);
throw error;