Skip to content

feat(mongo)!: ttl for reset password tokens #1081

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
9 changes: 8 additions & 1 deletion examples/rest-express-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
{
Expand All @@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/database-manager/__tests__/database-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
Expand Down
38 changes: 30 additions & 8 deletions packages/database-mongo-password/__tests__/mongo-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('MongoServicePassword', () => {

beforeEach(async () => {
await database.collection('users').deleteMany({});
await database.collection('resetPasswordTokens').deleteMany({});
});

afterAll(async () => {
Expand All @@ -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]],
});
});
});

Expand Down Expand Up @@ -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, '[email protected]', 'token', 'test');
await mongoServicePassword.addResetPasswordToken(
userId,
'[email protected]',
'token',
'test',
1000
);
const ret = await mongoServicePassword.findUserByResetPasswordToken('token');
expect(ret).toBeTruthy();
expect((ret as any)._id).toBeTruthy();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -553,15 +569,21 @@ describe('MongoServicePassword', () => {
});
const userId = await mongoServicePassword.createUser(user);
await expect(
mongoServicePassword.addResetPasswordToken(userId, '[email protected]', 'token', 'reset')
mongoServicePassword.addResetPasswordToken(userId, '[email protected]', '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, '[email protected]', 'token', 'reset');
const retUser = await mongoServicePassword.findUserById(userId);
await mongoServicePassword.addResetPasswordToken(
userId,
'[email protected]',
'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('[email protected]');
Expand Down
94 changes: 61 additions & 33 deletions packages/database-mongo-password/src/mongo-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand All @@ -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.
Expand All @@ -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'.
Expand Down Expand Up @@ -60,6 +80,8 @@ export interface MongoServicePasswordOptions {

const defaultOptions = {
userCollectionName: 'users',
resetPasswordTokenCollectionName: 'resetPasswordTokens',
resetPasswordTokenTTL: true,
timestamps: {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
Expand All @@ -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<MongoResetPasswordToken>;

constructor(options: MongoServicePasswordOptions) {
this.options = {
Expand All @@ -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
);
}

/**
Expand Down Expand Up @@ -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,
});
}
}

/**
Expand All @@ -129,7 +162,7 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword {
email,
...cleanUser
}: CreateUserServicePassword): Promise<string> {
const user: MongoUser = {
const user: Omit<MongoUser, '_id'> = {
...cleanUser,
services: {
password: {
Expand Down Expand Up @@ -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<User | null> {
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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -389,37 +428,26 @@ export class MongoServicePassword implements DatabaseInterfaceServicePassword {
userId: string,
email: string,
token: string,
reason: string
reason: string,
expireAfterSeconds: number
): Promise<void> {
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),
});
}

/**
* Remove all the reset password tokens for a user.
* @param userId Id used to update the user.
*/
public async removeAllResetPasswordTokens(userId: string): Promise<void> {
const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
await this.userCollection.updateOne(
{ _id: id },
{
$unset: {
'services.password.reset': '',
},
}
);
await this.resetPasswordTokenCollection.deleteMany({ userId });
}
}
28 changes: 23 additions & 5 deletions packages/database-mongo/__tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,13 @@ describe('Mongo', () => {

it('should return user', async () => {
const userId = await databaseTests.database.createUser(user);
await databaseTests.database.addResetPasswordToken(userId, '[email protected]', 'token', 'test');
await databaseTests.database.addResetPasswordToken(
userId,
'[email protected]',
'token',
'test',
1000
);
const ret = await databaseTests.database.findUserByResetPasswordToken('token');
expect(ret).toBeTruthy();
expect((ret as any)._id).toBeTruthy();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -832,13 +844,19 @@ describe('Mongo', () => {
idProvider: () => new ObjectId().toString(),
});
const userId = await mongoOptions.createUser(user);
await mongoOptions.addResetPasswordToken(userId, '[email protected]', 'token', 'reset');
await mongoOptions.addResetPasswordToken(userId, '[email protected]', 'token', 'reset', 1000);
});

it('should add a token', async () => {
const userId = await databaseTests.database.createUser(user);
await databaseTests.database.addResetPasswordToken(userId, '[email protected]', 'token', 'reset');
const retUser = await databaseTests.database.findUserById(userId);
await databaseTests.database.addResetPasswordToken(
userId,
'[email protected]',
'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('[email protected]');
Expand Down
11 changes: 9 additions & 2 deletions packages/database-mongo/src/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,16 @@ export class Mongo implements DatabaseInterface {
userId: string,
email: string,
token: string,
reason: string
reason: string,
expireAfterSeconds: number
): Promise<void> {
return this.servicePassword.addResetPasswordToken(userId, email, token, reason);
return this.servicePassword.addResetPasswordToken(
userId,
email,
token,
reason,
expireAfterSeconds
);
}

public async createSession(
Expand Down
4 changes: 2 additions & 2 deletions packages/password/__tests__/accounts-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down
Loading