@accounts/mongo-password
Version:
MongoDB password adaptor for accounts
458 lines (433 loc) • 13.7 kB
text/typescript
import { type Collection, type Db, type ObjectId, type CreateIndexesOptions } from 'mongodb';
import {
type CreateUserServicePassword,
type DatabaseInterfaceServicePassword,
type User,
} from '@accounts/types';
import { toMongoID } from './utils';
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export interface MongoUser {
_id?: string | object;
username?: string;
services: {
password?: {
bcrypt: string;
};
};
emails?: [
{
address: string;
verified: boolean;
},
];
[key: string]: any;
}
export interface MongoServicePasswordOptions {
/**
* Mongo database object.
*/
database: Db;
/**
* The users collection name.
* Default 'users'.
*/
userCollectionName?: string;
/**
* The timestamps for the users collection.
* Default 'createdAt' and 'updatedAt'.
*/
timestamps?: {
createdAt: string;
updatedAt: string;
};
/**
* Should the user collection use _id as string or ObjectId.
* Default 'true'.
*/
convertUserIdToMongoObjectId?: boolean;
/**
* Perform case intensitive query for user name.
* Default 'true'.
*/
caseSensitiveUserName?: boolean;
/**
* Function that generate the id for new objects.
*/
idProvider?: () => string | object;
/**
* Function that generate the date for the timestamps.
* Default to `(date?: Date) => (date ? date.getTime() : Date.now())`.
*/
dateProvider?: (date?: Date) => any;
}
const defaultOptions = {
userCollectionName: 'users',
timestamps: {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
convertUserIdToMongoObjectId: true,
caseSensitiveUserName: true,
dateProvider: (date?: Date) => (date ? date.getTime() : Date.now()),
};
export class MongoServicePassword implements DatabaseInterfaceServicePassword {
// Merged options that can be used
private options: MongoServicePasswordOptions & typeof defaultOptions;
// Mongo database object
private database: Db;
// Mongo user collection
private userCollection: Collection<
PartialBy<User & { _id?: string | object }, 'id' | 'deactivated'>
>;
constructor(options: MongoServicePasswordOptions) {
this.options = {
...defaultOptions,
...options,
timestamps: { ...defaultOptions.timestamps, ...options.timestamps },
};
this.database = this.options.database;
this.userCollection = this.database.collection(this.options.userCollectionName);
}
/**
* Setup the mongo indexes needed for the password service.
* @param options Options passed to the mongo native `createIndex` method.
*/
public async setupIndexes(
options: Omit<CreateIndexesOptions, 'unique' | 'sparse'> = {}
): Promise<void> {
// Username index to allow fast queries made with username
// Username is unique
await this.userCollection.createIndex('username', {
...options,
unique: true,
sparse: true,
});
// Emails index to allow fast queries made with emails, a user can have multiple emails
// Email address is unique
await this.userCollection.createIndex('emails.address', {
...options,
unique: true,
sparse: true,
});
// Token index used to verify the email address of a user
await this.userCollection.createIndex('services.email.verificationTokens.token', {
...options,
sparse: true,
});
// Token index used to verify a password reset request
await this.userCollection.createIndex('services.password.reset.token', {
...options,
sparse: true,
});
}
/**
* Create a new user by providing an email and/or a username and password.
* Emails are saved lowercased.
*/
public async createUser({
password,
username,
email,
...cleanUser
}: CreateUserServicePassword): Promise<string> {
const user: MongoUser = {
...cleanUser,
services: {
password: {
bcrypt: password,
},
},
[this.options.timestamps.createdAt]: this.options.dateProvider(),
[this.options.timestamps.updatedAt]: this.options.dateProvider(),
};
if (username) {
user.username = username;
}
if (email) {
user.emails = [{ address: email.toLowerCase(), verified: false }];
}
if (this.options.idProvider) {
user._id = this.options.idProvider();
}
const ret = await this.userCollection.insertOne(user);
// keep ret.ops for compatibility with MongoDB 3.X, version 4.X uses insertedId
return ((ret.insertedId ? ret.insertedId : (ret as any).ops[0]._id) as ObjectId).toString();
}
/**
* Get a user by his id.
* @param userId Id used to query the user.
*/
public async findUserById(userId: string): Promise<User | null> {
const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
const user = await this.userCollection.findOne({ _id: id });
if (user) {
user.id = user._id.toString();
}
return user as User;
}
/**
* Get a user by one of his emails.
* Email will be lowercased before running the query.
* @param email Email used to query the user.
*/
public async findUserByEmail(email: string): Promise<User | null> {
const user = await this.userCollection.findOne({
'emails.address': email.toLowerCase(),
});
if (user) {
user.id = user._id.toString();
}
return user as User;
}
/**
* Get a user by his username.
* Set the `caseSensitiveUserName` option to false if you want the username to be case sensitive.
* @param email Email used to query the user.
*/
public async findUserByUsername(username: string): Promise<User | null> {
const filter = this.options.caseSensitiveUserName
? { username }
: {
$where: `obj.username && (obj.username.toLowerCase() === "${username.toLowerCase()}")`,
};
const user = await this.userCollection.findOne(filter);
if (user) {
user.id = user._id.toString();
}
return user as User;
}
/**
* Return the user password hash.
* If the user has no password set, will return null.
* @param userId Id used to query the user.
*/
public async findPasswordHash(userId: string): Promise<string | null> {
const user = await this.findUserById(userId);
return user?.services?.password?.bcrypt ?? null;
}
/**
* Get a user by one of the email verification token.
* @param token Verification token used to query the user.
*/
public async findUserByEmailVerificationToken(token: string): Promise<User | null> {
const user = await this.userCollection.findOne({
'services.email.verificationTokens.token': token,
});
if (user) {
user.id = user._id.toString();
}
return user as User;
}
/**
* Get a user by one of the reset password token.
* @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,
});
if (user) {
user.id = user._id.toString();
}
return user as User;
}
/**
* Add an email address for a user.
* @param userId Id used to update the user.
* @param newEmail A new email address for the user.
* @param verified Whether the new email address should be marked as verified.
*/
public async addEmail(userId: string, newEmail: string, verified: boolean): Promise<void> {
const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
const ret = await this.userCollection.updateOne(
{ _id: id },
{
$addToSet: {
emails: {
address: newEmail.toLowerCase(),
verified,
},
},
$set: {
[this.options.timestamps.updatedAt]: this.options.dateProvider(),
},
}
);
if (
ret.modifiedCount === 0 ||
// keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
((ret as any).result && (ret as any).result.nModified === 0)
) {
throw new Error('User not found');
}
}
/**
* Remove an email address for a user.
* @param userId Id used to update the user.
* @param email The email address to remove.
*/
public async removeEmail(userId: string, email: string): Promise<void> {
const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
const ret = await this.userCollection.updateOne(
{ _id: id },
{
$pull: { emails: { address: email.toLowerCase() } },
$set: {
[this.options.timestamps.updatedAt]: this.options.dateProvider(),
},
}
);
if (
ret.modifiedCount === 0 ||
// keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
((ret as any).result && (ret as any).result.nModified === 0)
) {
throw new Error('User not found');
}
}
/**
* Marks the user's email address as verified.
* @param userId Id used to update the user.
* @param email The email address to mark as verified.
*/
public async verifyEmail(userId: string, email: string): Promise<void> {
const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
const ret = await this.userCollection.updateOne(
{ _id: id, 'emails.address': email },
{
$set: {
'emails.$.verified': true,
[this.options.timestamps.updatedAt]: this.options.dateProvider(),
},
$pull: { 'services.email.verificationTokens': { address: email } },
}
);
if (
ret.modifiedCount === 0 ||
// keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
((ret as any).result && (ret as any).result.nModified === 0)
) {
throw new Error('User not found');
}
}
/**
* Change the username of the user.
* If the username already exists, the function will fail.
* @param userId Id used to update the user.
* @param newUsername A new username for the user.
*/
public async setUsername(userId: string, newUsername: string): Promise<void> {
const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
const ret = await this.userCollection.updateOne(
{ _id: id },
{
$set: {
username: newUsername,
[this.options.timestamps.updatedAt]: this.options.dateProvider(),
},
}
);
if (
ret.modifiedCount === 0 ||
// keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
((ret as any).result && (ret as any).result.nModified === 0)
) {
throw new Error('User not found');
}
}
/**
* Change the password for a user.
* @param userId Id used to update the user.
* @param newPassword A new password for the user.
*/
public async setPassword(userId: string, newPassword: string): Promise<void> {
const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
const ret = await this.userCollection.updateOne(
{ _id: id },
{
$set: {
'services.password.bcrypt': newPassword,
[this.options.timestamps.updatedAt]: this.options.dateProvider(),
},
$unset: {
'services.password.reset': '',
},
}
);
if (
ret.modifiedCount === 0 ||
// keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
((ret as any).result && (ret as any).result.nModified === 0)
) {
throw new Error('User not found');
}
}
/**
* Add an email verification token to a user.
* @param userId Id used to update the user.
* @param email Which address of the user's to link the token to.
* @param token Random token used to verify the user email.
*/
public async addEmailVerificationToken(
userId: string,
email: string,
token: string
): Promise<void> {
const _id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
await this.userCollection.updateOne(
{ _id },
{
$push: {
'services.email.verificationTokens': {
token,
address: email.toLowerCase(),
when: this.options.dateProvider(),
},
},
}
);
}
/**
* Add a reset password token to a user.
* @param userId Id used to update the user.
* @param email Which address of the user's to link the token to.
* @param token Random token used to verify the user email.
* @param reason Reason to use for the token.
*/
public async addResetPasswordToken(
userId: string,
email: string,
token: string,
reason: string
): 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,
},
},
}
);
}
/**
* 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': '',
},
}
);
}
}