import crypto from 'crypto'; import { format } from 'date-fns'; import * as EmailValidator from 'email-validator'; import ms from 'ms'; import randomatic from 'randomatic'; import { EntityManager, getManager, MoreThanOrEqual, QueryFailedError, Transaction, TransactionManager, } from 'typeorm'; import zxcvbn from 'zxcvbn'; import { allComplete } from '@/utils'; import { VerificationRequest, VerificationRequestKind, } from '@/types/VerificationRequest'; import { TransactionalEmailSender } from '@/services'; import { UserRepository } from '@/repositories'; import { PersistedPassword } from '@/entities/types'; import { PasswordAuthenticationStrategy, ResetPasswordRequest, TimeBasedOneTimePassword, User, } from '@/entities'; import AbstractStrategy from '../AbstractStrategy'; import { InsecurePasswordError, InvalidEmailError, InvalidPasswordError, InvalidSignUpError, SignInResponse, SignUpWithEmailAndPassword, SignUpWithEmailAndPasswordOptions, UserNotFoundError, } from './types'; const DEFAULT_SALTED_PASSWORD_LENGTH = 256; const DEFAULT_SALT_LENGTH = 64; const DEFAULT_ITERATIONS = 10000; const DEFAULT_HASH_FUNCTION = 'sha256'; const DEFAULT_ENDCODING = 'base64'; const ALGORITH_VERSION = 1; /** * Designates how long each time-based one time password should live, * formatted to be parsed by {@link https://github.com/vercel/ms} */ const TOTP_EXPIRATION_TIME = '15m'; const MAX_OUTSTANDING_TOTPS = 10; /** * Designates how long each reset password request should be live * formatted to be parsed by {@link https://github.com/vercel/ms} */ const RESET_PASSWORD_EXPIRATION_TIME = '24h'; const MAX_OUTSTANDING_RESET_PASSWORD_LINKS = 10; export default class PasswordStrategy extends AbstractStrategy { private readonly transactionalEmailSender: TransactionalEmailSender; constructor() { super(); this.transactionalEmailSender = new TransactionalEmailSender(); } // @TODO: refactor into separate utility. private async generatePersistablePasswordData( password: string ): Promise { return new Promise((resolve, reject) => { const salt = crypto .randomBytes(DEFAULT_SALT_LENGTH) .toString(DEFAULT_ENDCODING); crypto.pbkdf2( password, salt, DEFAULT_ITERATIONS, DEFAULT_SALTED_PASSWORD_LENGTH, DEFAULT_HASH_FUNCTION, (error, hash) => { if (error) { reject(error); } else { resolve({ salt, hash: hash.toString(DEFAULT_ENDCODING), iterations: DEFAULT_ITERATIONS, length: DEFAULT_SALTED_PASSWORD_LENGTH, hashFunction: DEFAULT_HASH_FUNCTION, saltEncoding: DEFAULT_ENDCODING, algorithmVersion: ALGORITH_VERSION, }); } } ); }); } /** * Validates data for signing up with email and password * * @throws {InsecurePasswordError} if the password is too guessable. */ private validatePassword(options: { password: string; minScorePasswordScore?: number; }): void { const defaultOpitons = { minScorePasswordScore: 2, } as const; const { password, minScorePasswordScore } = { ...defaultOpitons, ...options, }; const { score, feedback } = zxcvbn(password); if (score < minScorePasswordScore) { throw new InsecurePasswordError(feedback); } } /** * Validates data for signing up with email and password * * @throws {InsecurePasswordError} if the password is too guessable. * @throws {InvalidEmailError} if the email is invalid. */ private async validateSignUpEmailAndPassword( options: SignUpWithEmailAndPasswordOptions ): Promise { const { email } = options; this.validatePassword(options); if (!EmailValidator.validate(email)) { throw new InvalidEmailError(email); } } /** * Signs up with email and password or fails with error. */ @Transaction() async signUpWithEmailAndPasswordOrFail( options: SignUpWithEmailAndPasswordOptions, @TransactionManager() manager: EntityManager = getManager() ): Promise { await this.validateSignUpEmailAndPassword(options); const passwordData = await this.generatePersistablePasswordData( options.password ); let user: User; try { user = await manager .getCustomRepository(UserRepository) .createFromPassword( { ...options, passwordData, }, manager ); } catch (error) { // NOTE: this check only works with PostGresQL // this is the code for PG_UNIQUE_CONSTRAINT_VIOLATION if ( error instanceof QueryFailedError && error.driverError?.code === '23505' ) { throw new InvalidSignUpError(options.email, options.username); } throw error; } const verificationRequested = await this.sendTOTPToUser(user, manager); return { ...(await this.getTokens({ user }, manager)), user, verificationRequested, }; } /** * Verifies the attempted password against the password information saved * in the database. * @TODO: refactor to separate utility */ async verifyPersistedPasswordOrFail({ passwordData, passwordAttempt, }: { passwordData: PersistedPassword; passwordAttempt: string; }): Promise { await new Promise((resolve, reject) => { crypto.pbkdf2( passwordAttempt, passwordData.salt, passwordData.iterations, passwordData.length, passwordData.hashFunction, async (error, passwordAttemptHash) => { if (error) { reject(error); } else { const passwordIsCorrect = crypto.timingSafeEqual( Buffer.from(passwordData.hash, passwordData.saltEncoding), passwordAttemptHash ); if (!passwordIsCorrect) { reject(new InvalidPasswordError()); return; } resolve(); } } ); }); } @Transaction() async signInOrFail( usernameOrEmail: string, passwordAttempt: string, @TransactionManager() manager: EntityManager = getManager() ): Promise { const user = await manager .getCustomRepository(UserRepository) .findByUsernameOrEmail(usernameOrEmail, manager); if (!user) { throw new UserNotFoundError(usernameOrEmail); } const passwordAuth = await user.getPasswordAuth(manager); if (!passwordAuth) { throw new Error('User does not have a saved password'); } const { passwordData } = passwordAuth; // verify password await this.verifyPersistedPasswordOrFail({ passwordData, passwordAttempt }); return { user, ...(await this.getTokens({ user }, manager)) }; } /** * Sends a time-based one time password to the user * @returns boolean - whether or not the TOTP was sent to the user */ @Transaction() async sendTOTPToUser( user: User, @TransactionManager() manager: EntityManager = getManager() ): Promise { const outstandingTOTPs = await manager.count(TimeBasedOneTimePassword, { expiresAt: MoreThanOrEqual(new Date()), user, }); if (outstandingTOTPs >= MAX_OUTSTANDING_TOTPS) { throw new Error('Cannot request more TOPTs if too many are outstanding'); } // generate a 6-digit code const code = randomatic('000000'); const otp = await this.generatePersistablePasswordData(code); const expiresAt = new Date(new Date().getTime() + ms(TOTP_EXPIRATION_TIME)); const data = manager.create(TimeBasedOneTimePassword, { expiresAt, otp, user, }); await manager.save(data, { reload: true }); const passwordAuth = await user.getPasswordAuth(manager); if (!passwordAuth) { throw new Error('User does not have a saved email'); } const to = passwordAuth.email; const subject = `Verification code: ${code}`; const signUpDate = format(user.createdAt, 'MM/dd/yyyy'); const text = `Hello${ user.username ? ` ${user.username}` : '' }, thank you for joining !\n\nIn order to finish signing up, you need to confirm your email address. Copy this code and enter it in your browser to complete the confirmation.\n\n${code}\n\nPlease note: This code will expire in 15 minutes.\n\nYou're receiving this email because you signed up for an account on ${signUpDate}. If you did not sign up for this account, you can ignore this email and the account will be deleted within 60 days.`; await this.transactionalEmailSender.send({ to, subject, text, }); return { expiresAt, sentTo: to, type: VerificationRequestKind.Email, requested: true, }; } /** * Verifies the user with a one time password or fails. * @returns {User} - The user if the OTP was valid */ @Transaction() async verifyUserWithOneTimePasswordOrFail( { user, otpAttempt }: { user: User; otpAttempt: string }, @TransactionManager() manager: EntityManager = getManager() ): Promise { if (otpAttempt.length === 0) { throw new Error('Provided OTP is not long enough'); } const matchingTOTPs = await manager.find(TimeBasedOneTimePassword, { where: { expiresAt: MoreThanOrEqual(new Date()), user, }, }); // will return if any of these don't throw an error and will throw if // all throw const successfulMatches = ( await Promise.allSettled( matchingTOTPs.map(async (totp) => { await this.verifyPersistedPasswordOrFail({ passwordData: totp.otp, passwordAttempt: otpAttempt, }); return totp; }) ) ).filter( (result): result is PromiseFulfilledResult => result.status === 'fulfilled' ); if (successfulMatches.length > 0) { // mark them all as used await allComplete( successfulMatches.map(async ({ value: totp }) => { await manager.softRemove(totp); }) ); return manager .getCustomRepository(UserRepository) .setVerifiedOrFail({ user }); } else { throw new Error('OTP attempt did not match known TOPTs'); } } /** * Changes the user's password or fails. */ @Transaction() async changePasswordOrFail( { oldPassword, newPassword, userId, }: { userId: string; oldPassword: string; newPassword: string }, @TransactionManager() manager: EntityManager = getManager() ): Promise { const user = await manager.findOneOrFail(User, userId); const passwordAuth = await user.getPasswordAuth(manager); if (!passwordAuth) { throw new Error('User does not have a saved email'); } const { passwordData } = passwordAuth; // verify password await this.verifyPersistedPasswordOrFail({ passwordData, passwordAttempt: oldPassword, }); this.validatePassword({ password: newPassword }); const newPasswordData = await this.generatePersistablePasswordData( newPassword ); await manager.update(PasswordAuthenticationStrategy, passwordAuth.id, { passwordData: newPasswordData, }); } /** * Resets the user's password and sends the reset password email or fails. */ @Transaction() async requestResetPasswordOrFail( usernameOrEmail: string, @TransactionManager() manager: EntityManager = getManager() ): Promise { if (!process.env.WEB_URL_ROOT) { throw new Error( 'Cannot complete reset to password without WEB_URL_ROOT in environment.' ); } const user = await manager .getCustomRepository(UserRepository) .findByUsernameOrEmail(usernameOrEmail, manager); if (!user) { throw new UserNotFoundError(usernameOrEmail); } const outstandingPasswordResetLinks = await manager.count( ResetPasswordRequest, { expiresAt: MoreThanOrEqual(new Date()), user, } ); if (outstandingPasswordResetLinks >= MAX_OUTSTANDING_RESET_PASSWORD_LINKS) { throw new Error( 'Cannot request more reset password links if too many are outstanding.' ); } const resetKey = randomatic('?', 32, { // only use non-ambiguous characters chars: '23456789abcdefghjkmnpqrstuvwxyz', }); const key = await this.generatePersistablePasswordData(resetKey); const expiresAt = new Date( new Date().getTime() + ms(RESET_PASSWORD_EXPIRATION_TIME) ); await manager.save( manager.create(ResetPasswordRequest, { key, user, expiresAt, }), { reload: true } ); const passwordAuth = await user.getPasswordAuth(manager); if (!passwordAuth) { throw new Error('User does not have a saved password.'); } const { email } = passwordAuth; const persistablePasswordLink = `${process.env.WEB_URL_ROOT}/reset/${resetKey}?email=${email}`; const to = email; const subject = 'Reset your password'; const text = `Hello${ user.username ? ` ${user.username}` : '' }\n\nYou asked us to send you a link to reset your password. Please note: this link will expire in 24 hours and can only be used one time. If you did not make this request, please delete this email. Your password will not be changed if you do not proceed.\n\nReset password: ${persistablePasswordLink}`; await this.transactionalEmailSender.send({ to, subject, text, }); } @Transaction() async changePasswordWithResetPasswordKeyOrFail( { resetPasswordKey, newPassword, usernameOrEmail, }: { resetPasswordKey: string; newPassword: string; usernameOrEmail: string; }, @TransactionManager() manager: EntityManager ): Promise { const user = await manager .getCustomRepository(UserRepository) .findByUsernameOrEmail(usernameOrEmail, manager); if (!user) { throw new UserNotFoundError(usernameOrEmail); } this.validatePassword({ password: newPassword }); const matchingResetPasswordRequests = await manager.find( ResetPasswordRequest, { where: { expiresAt: MoreThanOrEqual(new Date()), user, }, } ); // will return if any of these don't throw an error and will throw if // all throw const successfulMatches = ( await Promise.allSettled( matchingResetPasswordRequests.map(async (request) => { await this.verifyPersistedPasswordOrFail({ passwordData: request.key, passwordAttempt: resetPasswordKey, }); return request; }) ) ).filter( (result): result is PromiseFulfilledResult => result.status === 'fulfilled' ); if (successfulMatches.length > 0) { // mark them all as used await allComplete( successfulMatches.map(async ({ value: request }) => { await manager.softRemove(request); }) ); const newPasswordData = await this.generatePersistablePasswordData( newPassword ); const passwordAuth = await user.getPasswordAuth(manager); if (!passwordAuth) { throw new Error('User does not have a saved email.'); } await manager.update(PasswordAuthenticationStrategy, passwordAuth.id, { passwordData: newPasswordData, }); } else { throw new Error( 'Reset password key did not match known reset password requests' ); } } }