diff --git a/examples/rest-express-typescript/src/index.ts b/examples/rest-express-typescript/src/index.ts index 9816475ee..090cbd4e0 100644 --- a/examples/rest-express-typescript/src/index.ts +++ b/examples/rest-express-typescript/src/index.ts @@ -42,9 +42,11 @@ const accountsPassword = new AccountsPassword({ }, }); +const accountsMongo = new Mongo(db); + const accountsServer = new AccountsServer( { - db: new Mongo(db), + db: accountsMongo, tokenSecret: 'secret', }, { @@ -58,6 +60,11 @@ accountsServer.on(ServerHooks.ValidateLogin, ({ user }) => { // If you throw an error here it will be returned to the client. }); +// Set the required mongodb indexes once connection is successful +mongoose.connection.on('connected', async () => { + await accountsMongo.setupIndexes(); +}); + /** * Load and expose the accounts-js middleware */ diff --git a/packages/database-manager/__tests__/database-manager.ts b/packages/database-manager/__tests__/database-manager.ts index dd4810c10..5745af1ed 100644 --- a/packages/database-manager/__tests__/database-manager.ts +++ b/packages/database-manager/__tests__/database-manager.ts @@ -231,7 +231,7 @@ describe('DatabaseManager', () => { }); it('addResetPasswordToken should be called on sessionStorage', () => { - expect(databaseManager.addResetPasswordToken('userId', 'email', 'token', 'reason')).toBe( + expect(databaseManager.addResetPasswordToken('userId', 'email', 'token', 'reason', 1000)).toBe( 'userStorage' ); }); diff --git a/packages/database-mongo-password/__tests__/mongo-password.ts b/packages/database-mongo-password/__tests__/mongo-password.ts index 214647f46..604a19457 100644 --- a/packages/database-mongo-password/__tests__/mongo-password.ts +++ b/packages/database-mongo-password/__tests__/mongo-password.ts @@ -24,6 +24,7 @@ describe('MongoServicePassword', () => { beforeEach(async () => { await database.collection('users').deleteMany({}); + await database.collection('resetPasswordTokens').deleteMany({}); }); afterAll(async () => { @@ -49,16 +50,19 @@ describe('MongoServicePassword', () => { it('should create indexes', async () => { const mongoServicePassword = new MongoServicePassword({ database }); await mongoServicePassword.setupIndexes(); - const ret = await database.collection('users').indexInformation(); - expect(ret).toEqual({ + expect(await database.collection('users').indexInformation()).toEqual({ _id_: [['_id', 1]], 'emails.address_1': [['emails.address', 1]], 'services.email.verificationTokens.token_1': [ ['services.email.verificationTokens.token', 1], ], - 'services.password.reset.token_1': [['services.password.reset.token', 1]], username_1: [['username', 1]], }); + expect(await database.collection('resetPasswordTokens').indexInformation()).toEqual({ + _id_: [['_id', 1]], + expireAt_1: [['expireAt', 1]], + token_1: [['token', 1]], + }); }); }); @@ -270,7 +274,13 @@ describe('MongoServicePassword', () => { it('should return user', async () => { const mongoServicePassword = new MongoServicePassword({ database }); const userId = await mongoServicePassword.createUser(user); - await mongoServicePassword.addResetPasswordToken(userId, 'john@doe.com', 'token', 'test'); + await mongoServicePassword.addResetPasswordToken( + userId, + 'john@doe.com', + 'token', + 'test', + 1000 + ); const ret = await mongoServicePassword.findUserByResetPasswordToken('token'); expect(ret).toBeTruthy(); expect((ret as any)._id).toBeTruthy(); @@ -505,7 +515,13 @@ describe('MongoServicePassword', () => { const testToken = 'testVerificationToken'; const testReason = 'testReason'; const userId = await mongoServicePassword.createUser(user); - await mongoServicePassword.addResetPasswordToken(userId, user.email, testToken, testReason); + await mongoServicePassword.addResetPasswordToken( + userId, + user.email, + testToken, + testReason, + 1000 + ); const userWithTokens = await mongoServicePassword.findUserByResetPasswordToken(testToken); expect(userWithTokens).toBeTruthy(); await mongoServicePassword.removeAllResetPasswordTokens(userId); @@ -553,15 +569,21 @@ describe('MongoServicePassword', () => { }); const userId = await mongoServicePassword.createUser(user); await expect( - mongoServicePassword.addResetPasswordToken(userId, 'john@doe.com', 'token', 'reset') + mongoServicePassword.addResetPasswordToken(userId, 'john@doe.com', 'token', 'reset', 1000) ).resolves.not.toThrowError(); }); it('should add a token', async () => { const mongoServicePassword = new MongoServicePassword({ database }); const userId = await mongoServicePassword.createUser(user); - await mongoServicePassword.addResetPasswordToken(userId, 'john@doe.com', 'token', 'reset'); - const retUser = await mongoServicePassword.findUserById(userId); + await mongoServicePassword.addResetPasswordToken( + userId, + 'john@doe.com', + 'token', + 'reset', + 1000 + ); + const retUser = await mongoServicePassword.findUserByResetPasswordToken('token'); const services: any = retUser!.services; expect(services.password.reset.length).toEqual(1); expect(services.password.reset[0].address).toEqual('john@doe.com'); diff --git a/packages/database-mongo-password/src/mongo-password.ts b/packages/database-mongo-password/src/mongo-password.ts index d871d8bc4..317798df3 100644 --- a/packages/database-mongo-password/src/mongo-password.ts +++ b/packages/database-mongo-password/src/mongo-password.ts @@ -3,7 +3,7 @@ import { CreateUserServicePassword, DatabaseInterfaceServicePassword, User } fro import { toMongoID } from './utils'; export interface MongoUser { - _id?: string | object; + _id: string | object; username?: string; services: { password?: { @@ -19,6 +19,16 @@ export interface MongoUser { [key: string]: any; } +export interface MongoResetPasswordToken { + _id: string | object; + userId: string; + token: string; + address: string; + when: Date; + reason: string; + expireAt: Date; +} + export interface MongoServicePasswordOptions { /** * Mongo database object. @@ -29,6 +39,16 @@ export interface MongoServicePasswordOptions { * Default 'users'. */ userCollectionName?: string; + /** + * The password reset token collection name; + * Default 'resetPasswordTokens'. + */ + resetPasswordTokenCollectionName?: string; + /** + * Should automatically delete the user reset password tokens when they expire via mongo TTL. + * Default to 'true'. + */ + resetPasswordTokenTTL?: boolean; /** * The timestamps for the users collection. * Default 'createdAt' and 'updatedAt'. @@ -60,6 +80,8 @@ export interface MongoServicePasswordOptions { const defaultOptions = { userCollectionName: 'users', + resetPasswordTokenCollectionName: 'resetPasswordTokens', + resetPasswordTokenTTL: true, timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt', @@ -76,6 +98,8 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { private database: Db; // Mongo user collection private userCollection: Collection; + // Mongo password reset token collection + private resetPasswordTokenCollection: Collection; constructor(options: MongoServicePasswordOptions) { this.options = { @@ -86,6 +110,9 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { this.database = this.options.database; this.userCollection = this.database.collection(this.options.userCollectionName); + this.resetPasswordTokenCollection = this.database.collection( + this.options.resetPasswordTokenCollectionName + ); } /** @@ -113,10 +140,16 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { sparse: true, }); // Token index used to verify a password reset request - await this.userCollection.createIndex('services.password.reset.token', { + await this.resetPasswordTokenCollection.createIndex('token', { ...options, + unique: true, sparse: true, }); + if (this.options.resetPasswordTokenTTL) { + await this.resetPasswordTokenCollection.createIndex('expireAt', { + expireAfterSeconds: 0, + }); + } } /** @@ -129,7 +162,7 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { email, ...cleanUser }: CreateUserServicePassword): Promise { - const user: MongoUser = { + const user: Omit = { ...cleanUser, services: { password: { @@ -227,11 +260,20 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { * @param token Reset password token used to query the user. */ public async findUserByResetPasswordToken(token: string): Promise { - const user = await this.userCollection.findOne({ - 'services.password.reset.token': token, - }); + const resetPasswordToken = await this.resetPasswordTokenCollection.findOne({ token }); + if (!resetPasswordToken) { + return null; + } + + const userId = this.options.convertUserIdToMongoObjectId + ? toMongoID(resetPasswordToken.userId) + : resetPasswordToken.userId; + const user = await this.userCollection.findOne({ _id: userId }); if (user) { user.id = user._id.toString(); + user.services.password.reset = [ + { ...resetPasswordToken, when: resetPasswordToken.when.getTime() }, + ]; } return user; } @@ -342,9 +384,6 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { 'services.password.bcrypt': newPassword, [this.options.timestamps.updatedAt]: this.options.dateProvider(), }, - $unset: { - 'services.password.reset': '', - }, } ); if (ret.result.nModified === 0) { @@ -389,22 +428,19 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { userId: string, email: string, token: string, - reason: string + reason: string, + expireAfterSeconds: number ): Promise { - const _id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId; - await this.userCollection.updateOne( - { _id }, - { - $push: { - 'services.password.reset': { - token, - address: email.toLowerCase(), - when: this.options.dateProvider(), - reason, - }, - }, - } - ); + const now = new Date(); + await this.resetPasswordTokenCollection.insertOne({ + userId, + address: email.toLowerCase(), + token, + when: now, + reason, + // Set when the object should be removed by mongo TTL + expireAt: new Date(now.getTime() + expireAfterSeconds * 1000), + }); } /** @@ -412,14 +448,6 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword { * @param userId Id used to update the user. */ public async removeAllResetPasswordTokens(userId: string): Promise { - const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId; - await this.userCollection.updateOne( - { _id: id }, - { - $unset: { - 'services.password.reset': '', - }, - } - ); + await this.resetPasswordTokenCollection.deleteMany({ userId }); } } diff --git a/packages/database-mongo/__tests__/index.ts b/packages/database-mongo/__tests__/index.ts index 343a06f85..c6bcc4f66 100644 --- a/packages/database-mongo/__tests__/index.ts +++ b/packages/database-mongo/__tests__/index.ts @@ -267,7 +267,13 @@ describe('Mongo', () => { it('should return user', async () => { const userId = await databaseTests.database.createUser(user); - await databaseTests.database.addResetPasswordToken(userId, 'john@doe.com', 'token', 'test'); + await databaseTests.database.addResetPasswordToken( + userId, + 'john@doe.com', + 'token', + 'test', + 1000 + ); const ret = await databaseTests.database.findUserByResetPasswordToken('token'); expect(ret).toBeTruthy(); expect((ret as any)._id).toBeTruthy(); @@ -790,7 +796,13 @@ describe('Mongo', () => { const testToken = 'testVerificationToken'; const testReason = 'testReason'; const userId = await databaseTests.database.createUser(user); - await databaseTests.database.addResetPasswordToken(userId, user.email, testToken, testReason); + await databaseTests.database.addResetPasswordToken( + userId, + user.email, + testToken, + testReason, + 1000 + ); const userWithTokens = await databaseTests.database.findUserByResetPasswordToken(testToken); expect(userWithTokens).toBeTruthy(); await databaseTests.database.removeAllResetPasswordTokens(userId); @@ -832,13 +844,19 @@ describe('Mongo', () => { idProvider: () => new ObjectId().toString(), }); const userId = await mongoOptions.createUser(user); - await mongoOptions.addResetPasswordToken(userId, 'john@doe.com', 'token', 'reset'); + await mongoOptions.addResetPasswordToken(userId, 'john@doe.com', 'token', 'reset', 1000); }); it('should add a token', async () => { const userId = await databaseTests.database.createUser(user); - await databaseTests.database.addResetPasswordToken(userId, 'john@doe.com', 'token', 'reset'); - const retUser = await databaseTests.database.findUserById(userId); + await databaseTests.database.addResetPasswordToken( + userId, + 'john@doe.com', + 'token', + 'reset', + 1000 + ); + const retUser = await databaseTests.database.findUserByResetPasswordToken('token'); const services: any = retUser!.services; expect(services.password.reset.length).toEqual(1); expect(services.password.reset[0].address).toEqual('john@doe.com'); diff --git a/packages/database-mongo/src/mongo.ts b/packages/database-mongo/src/mongo.ts index cd5f3cbcd..dccf1b8f5 100644 --- a/packages/database-mongo/src/mongo.ts +++ b/packages/database-mongo/src/mongo.ts @@ -128,9 +128,16 @@ export class Mongo implements DatabaseInterface { userId: string, email: string, token: string, - reason: string + reason: string, + expireAfterSeconds: number ): Promise { - return this.servicePassword.addResetPasswordToken(userId, email, token, reason); + return this.servicePassword.addResetPasswordToken( + userId, + email, + token, + reason, + expireAfterSeconds + ); } public async createSession( diff --git a/packages/password/__tests__/accounts-password.ts b/packages/password/__tests__/accounts-password.ts index 151220597..bffa065e0 100644 --- a/packages/password/__tests__/accounts-password.ts +++ b/packages/password/__tests__/accounts-password.ts @@ -574,7 +574,7 @@ describe('AccountsPassword', () => { } as any; set(password.server, 'options.emailTemplates', {}); await password.sendResetPasswordEmail(email); - expect(addResetPasswordToken.mock.calls[0].length).toBe(4); + expect(addResetPasswordToken.mock.calls[0].length).toBe(5); expect(prepareMail.mock.calls[0].length).toBe(6); expect(sendMail.mock.calls[0].length).toBe(1); }); @@ -610,7 +610,7 @@ describe('AccountsPassword', () => { } as any; set(password.server, 'options.emailTemplates', {}); await password.sendEnrollmentEmail(email); - expect(addResetPasswordToken.mock.calls[0].length).toBe(4); + expect(addResetPasswordToken.mock.calls[0].length).toBe(5); expect(prepareMail.mock.calls[0].length).toBe(6); expect(sendMail.mock.calls[0].length).toBe(1); }); diff --git a/packages/password/src/accounts-password.ts b/packages/password/src/accounts-password.ts index 8f3feab27..656865215 100644 --- a/packages/password/src/accounts-password.ts +++ b/packages/password/src/accounts-password.ts @@ -56,7 +56,7 @@ export interface AccountsPasswordOptions { */ passwordResetTokenExpiration?: number; /** - * The number of milliseconds from when a link to set inital password is sent until token expires and user can't set password with the link anymore. + * The number of milliseconds from when a link to set initial password is sent until token expires and user can't set password with the link anymore. * Defaults to 30 days. */ passwordEnrollTokenExpiration?: number; @@ -530,7 +530,13 @@ export default class AccountsPassword ); } const token = generateRandomToken(); - await this.db.addResetPasswordToken(user.id, address, token, 'reset'); + await this.db.addResetPasswordToken( + user.id, + address, + token, + 'reset', + this.options.passwordResetTokenExpiration / 1000 + ); const resetPasswordMail = this.server.prepareMail( address, @@ -569,7 +575,13 @@ export default class AccountsPassword ); } const token = generateRandomToken(); - await this.db.addResetPasswordToken(user.id, address, token, 'enroll'); + await this.db.addResetPasswordToken( + user.id, + address, + token, + 'enroll', + this.options.passwordEnrollTokenExpiration / 1000 + ); const enrollmentMail = this.server.prepareMail( address, diff --git a/packages/types/src/types/services/password/database-interface.ts b/packages/types/src/types/services/password/database-interface.ts index f7824b038..ea999cd2c 100644 --- a/packages/types/src/types/services/password/database-interface.ts +++ b/packages/types/src/types/services/password/database-interface.ts @@ -22,7 +22,8 @@ export interface DatabaseInterfaceServicePassword; addEmail(userId: string, newEmail: string, verified: boolean): Promise;