UNPKG

@accounts/password

Version:

[![npm](https://img.shields.io/npm/v/@accounts/password)](https://www.npmjs.com/package/@accounts/password) [![npm downloads](https://img.shields.io/npm/dm/@accounts/password)](https://www.npmjs.com/package/@accounts/password) [![codecov](https://img.shie

806 lines (729 loc) 27.7 kB
import { type User, type LoginUserIdentity, type EmailRecord, type TokenRecord, type DatabaseInterface, type AuthenticationService, type ConnectionInformations, type LoginResult, type CreateUserServicePassword, type LoginUserPasswordService, type DatabaseInterfaceSessions, type DatabaseInterfaceUser, } from '@accounts/types'; import { TwoFactor, type AccountsTwoFactorOptions, getUserTwoFactorService, } from '@accounts/two-factor'; import { AccountsServer, ServerHooks, generateRandomToken, AccountsJsError, DatabaseInterfaceUserToken, DatabaseInterfaceSessionsToken, } from '@accounts/server'; import { getUserResetTokens, getUserVerificationTokens, bcryptPassword, verifyPassword, isEmail, } from './utils'; import { AccountsPasswordConfigToken, type ErrorMessages } from './types'; import { errors, AddEmailErrors, AuthenticateErrors, ChangePasswordErrors, CreateUserErrors, PasswordAuthenticatorErrors, ResetPasswordErrors, SendVerificationEmailErrors, SendResetPasswordEmailErrors, SendEnrollmentEmailErrors, VerifyEmailErrors, } from './errors'; import { isObject, isString } from './utils/validation'; import { Inject, Injectable } from 'graphql-modules'; export interface AccountsPasswordOptions { /** * Two factor options passed down to the @accounts/two-factor service. */ twoFactor?: AccountsTwoFactorOptions; /** * Whether the email needs to be verified in order to allow authentication. * From an user enumeration perspective changes what is safe to return when ambiguousErrorMessages are enabled. * Can be enabled only if enableAutologin is set to false. * Defaults to false. */ requireEmailVerification?: boolean; /** * The number of milliseconds from when a link to verify the user email is sent until token expires and user can't verify his email with the link anymore. * Defaults to 3 days. */ verifyEmailTokenExpiration?: number; /** * The number of milliseconds from when a link to reset password is sent until token expires and user can't reset password with the link anymore. * Defaults to 3 days. */ 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. * Defaults to 30 days. */ passwordEnrollTokenExpiration?: number; /** * Accounts password module errors */ errors?: ErrorMessages; /** * Notify a user after his password has been changed. * This email is sent when the user reset his password and when he change it. * Default to true. */ notifyUserAfterPasswordChanged?: boolean; /** * Default to false. */ returnTokensAfterResetPassword?: boolean; /** * Invalidate existing sessions after password has been reset * Default to true. */ invalidateAllSessionsAfterPasswordReset?: boolean; /** * Invalidate existing sessions after password has been changed * Default to false. */ invalidateAllSessionsAfterPasswordChanged?: boolean; /** * Will remove all password reset tokens from the db after a password has been changed. * Default to true. */ removeAllResetPasswordTokensAfterPasswordChanged?: boolean; /** * Will automatically send a verification email after signup. * Default to false. */ sendVerificationEmailAfterSignup?: boolean; /** * Function that will validate the user object during `createUser`. * The user returned from this function will be directly inserted in the database so be careful when you whitelist the fields, * By default we only allow `username`, `email` and `password` fields. */ validateNewUser?: ( user: CreateUserServicePassword ) => Promise<CreateUserServicePassword> | CreateUserServicePassword; /** * Function that check if the email is a valid email. * This function will be called when you call `createUser` and `addEmail`. */ validateEmail?: (email?: string) => boolean; /** * Function that check if the password is valid. * This function will be called when you call `createUser` and `changePassword`. */ validatePassword?: <T extends User>(password?: string, user?: T) => Promise<boolean>; /** * Function that check if the username is a valid username. * This function will be called when you call `createUser`. */ validateUsername?: (username?: string) => boolean; /** * Function called to hash the user password, the password returned will be saved * in the database directly. By default we use bcrypt to hash the password. * Use this option alongside `verifyPassword` if you want to use argon2 for example. */ hashPassword?: (password: string) => Promise<string>; /** * Function called to verify the password hash. By default we use bcrypt to hash the password. * Use this option alongside `hashPassword` if you want to use argon2 for example. */ verifyPassword?: (password: string, hash: string) => Promise<boolean>; } const defaultOptions = { requireEmailVerification: false, // 3 days - 3 * 24 * 60 * 60 * 1000 verifyEmailTokenExpiration: 259200000, // 3 days - 3 * 24 * 60 * 60 * 1000 passwordResetTokenExpiration: 259200000, // 30 days - 30 * 24 * 60 * 60 * 1000 passwordEnrollTokenExpiration: 2592000000, notifyUserAfterPasswordChanged: true, returnTokensAfterResetPassword: false, invalidateAllSessionsAfterPasswordReset: true, invalidateAllSessionsAfterPasswordChanged: false, removeAllResetPasswordTokensAfterPasswordChanged: true, errors, sendVerificationEmailAfterSignup: false, validateEmail(email?: string): boolean { return isString(email) && isEmail(email); }, async validatePassword(password?: string): Promise<boolean> { return isString(password) && password !== ''; }, validateUsername(username?: string): boolean { const usernameRegex = /^[a-zA-Z][a-zA-Z0-9]*$/; return isString(username) && usernameRegex.test(username); }, // If user does not provide the validateNewUser function only allow some fields validateNewUser( user: CreateUserServicePassword ): Promise<CreateUserServicePassword> | CreateUserServicePassword { const safeUser: CreateUserServicePassword = { password: user.password }; if (user.username) { safeUser.username = user.username; } if (user.email) { safeUser.email = user.email; } return safeUser; }, hashPassword: bcryptPassword, verifyPassword, }; @Injectable({ global: true, }) export default class AccountsPassword<CustomUser extends User = User> implements AuthenticationService<CustomUser> { public serviceName = 'password'; public server!: AccountsServer; public twoFactor: TwoFactor; options: AccountsPasswordOptions & typeof defaultOptions; private db!: DatabaseInterfaceUser<CustomUser>; private dbSessions!: DatabaseInterfaceSessions; constructor( @Inject(AccountsPasswordConfigToken) options: AccountsPasswordOptions = {}, @Inject(DatabaseInterfaceUserToken) db?: DatabaseInterface<CustomUser> | DatabaseInterfaceUser<CustomUser>, @Inject(DatabaseInterfaceSessionsToken) dbSessions?: DatabaseInterfaceSessions, @Inject(AccountsServer) server?: AccountsServer ) { this.options = { ...defaultOptions, ...options } as typeof options & typeof defaultOptions; if (this.options.requireEmailVerification) { if (server?.options.enableAutologin) { throw new Error( "Can't enable autologin when requireEmailVerification is enabled. Please set either of them to false." ); } // AccountsPassword has been manually instantiated so there is no way to access the AccountsServer options if (!server) { console.log( "Please ensure that 'enableAutologin' has not been set to true in AccountsServer." ); } } this.twoFactor = new TwoFactor(options.twoFactor); if (db) { this.db = db; this.dbSessions = dbSessions ?? (db as DatabaseInterfaceSessions); } if (server) { this.server = server; } } public setUserStore(store: DatabaseInterfaceUser<CustomUser>) { this.db = store; this.twoFactor.setUserStore(store); } public setSessionsStore(store?: DatabaseInterfaceSessions) { this.dbSessions = store ?? (this.db as unknown as DatabaseInterfaceSessions); } public async authenticate(params: LoginUserPasswordService): Promise<CustomUser> { const { user, password, code } = params; if (!user || !password) { throw new AccountsJsError( this.options.errors.unrecognizedOptionsForLogin, AuthenticateErrors.UnrecognizedOptionsForLogin ); } if ((!isString(user) && !isObject(user)) || !isString(password)) { throw new AccountsJsError(this.options.errors.matchFailed, AuthenticateErrors.MatchFailed); } const foundUser = await this.passwordAuthenticator(user, password); // If user activated two factor authentication try with the code if (getUserTwoFactorService(foundUser)) { await this.twoFactor.authenticate(foundUser, code!); } return foundUser; } /** * @description Find a user by one of his emails. * @param {string} email - User email. * @returns {Promise<Object>} - Return a user or null if not found. */ public findUserByEmail(email: string): Promise<CustomUser | null> { return this.db.findUserByEmail(email); } /** * @description Find a user by his username. * @param {string} username - User username. * @returns {Promise<Object>} - Return a user or null if not found. */ public findUserByUsername(username: string): Promise<CustomUser | null> { return this.db.findUserByUsername(username); } /** * @description Add an email address for a user. * It will trigger the `validateEmail` option and throw if email is invalid. * Use this instead of directly updating the database. * @param {string} userId - User id. * @param {string} newEmail - A new email address for the user. * @param {boolean} [verified] - Whether the new email address should be marked as verified. * Defaults to false. * @returns {Promise<void>} - Return a Promise. * @throws {@link AddEmailErrors} */ public addEmail(userId: string, newEmail: string, verified = false): Promise<void> { if (!this.options.validateEmail(newEmail)) { throw new AccountsJsError(this.options.errors.invalidEmail, AddEmailErrors.InvalidEmail); } return this.db.addEmail(userId, newEmail, verified); } /** * @description Remove an email address for a user. * Use this instead of directly updating the database. * @param {string} userId - User id. * @param {string} email - The email address to remove. * @returns {Promise<void>} - Return a Promise. */ public removeEmail(userId: string, email: string): Promise<void> { return this.db.removeEmail(userId, email); } /** * @description Marks the user's email address as verified. * @param {string} token - The token retrieved from the verification URL. * @returns {Promise<void>} - Return a Promise. * @throws {@link VerifyEmailErrors} */ public async verifyEmail(token: string): Promise<void> { if (!token || !isString(token)) { throw new AccountsJsError(this.options.errors.invalidToken, VerifyEmailErrors.InvalidToken); } const user = await this.db.findUserByEmailVerificationToken(token); if (!user) { throw new AccountsJsError( this.options.errors.verifyEmailLinkExpired, VerifyEmailErrors.VerifyEmailLinkExpired ); } const verificationTokens = getUserVerificationTokens(user); const tokenRecord = verificationTokens.find((t: TokenRecord) => t.token === token); if (!tokenRecord || this.isTokenExpired(tokenRecord, this.options.verifyEmailTokenExpiration)) { throw new AccountsJsError( this.options.errors.verifyEmailLinkExpired, VerifyEmailErrors.VerifyEmailLinkExpired ); } const emailRecord = user.emails?.find((e: EmailRecord) => e.address === tokenRecord.address); if (!emailRecord) { throw new AccountsJsError( this.options.errors.verifyEmailLinkUnknownAddress, VerifyEmailErrors.VerifyEmailLinkUnknownAddress ); } await this.db.verifyEmail(user.id, emailRecord.address); } /** * @description Reset the password for a user using a token received in email. * It will trigger the `validatePassword` option and throw if password is invalid. * @param {string} token - The token retrieved from the reset password URL. * @param {string} newPassword - A new password for the user. * @returns {Promise<LoginResult | null>} - If `returnTokensAfterResetPassword` option is true return the session tokens and user object, otherwise return null. * @throws {@link ResetPasswordErrors} */ public async resetPassword( token: string, newPassword: string, infos: ConnectionInformations ): Promise<LoginResult | null> { if (!token || !isString(token)) { throw new AccountsJsError(this.options.errors.invalidToken, ResetPasswordErrors.InvalidToken); } const user = await this.db.findUserByResetPasswordToken(token); if (!user) { throw new AccountsJsError( this.options.errors.resetPasswordLinkExpired, ResetPasswordErrors.ResetPasswordLinkExpired ); } if (!(await this.options.validatePassword(newPassword, user))) { throw new AccountsJsError( this.options.errors.invalidNewPassword, ResetPasswordErrors.InvalidNewPassword ); } const resetTokens = getUserResetTokens(user); const resetTokenRecord = resetTokens.find((t) => t.token === token); if ( !resetTokenRecord || this.isTokenExpired( resetTokenRecord, resetTokenRecord.reason === 'enroll' ? this.options.passwordEnrollTokenExpiration : this.options.passwordResetTokenExpiration ) ) { throw new AccountsJsError( this.options.errors.resetPasswordLinkExpired, ResetPasswordErrors.ResetPasswordLinkExpired ); } const emails = user.emails || []; if (!emails.map((email: EmailRecord) => email.address).includes(resetTokenRecord.address)) { throw new AccountsJsError( this.options.errors.resetPasswordLinkUnknownAddress, ResetPasswordErrors.ResetPasswordLinkUnknownAddress ); } const password = await this.options.hashPassword(newPassword); // Change the user password and remove the other reset tokens await this.db.setPassword(user.id, password); await this.db.removeAllResetPasswordTokens(user.id); await this.server.getHooks().emit(ServerHooks.ResetPasswordSuccess, user); // If user clicked on an enrollment link we can verify his email if (resetTokenRecord.reason === 'enroll') { await this.db.verifyEmail(user.id, resetTokenRecord.address); } // Changing the password should invalidate existing sessions if (this.options.invalidateAllSessionsAfterPasswordReset) { await this.dbSessions.invalidateAllSessions(user.id); } if (this.options.notifyUserAfterPasswordChanged) { const address = user.emails && user.emails[0].address; if (!address) { throw new AccountsJsError(this.options.errors.noEmailSet, ResetPasswordErrors.NoEmailSet); } const passwordChangedMail = this.server.prepareMail( address, '', this.server.sanitizeUser(user), '', this.server.options.emailTemplates.passwordChanged, this.server.options.emailTemplates.from ); await this.server.options.sendMail(passwordChangedMail); } if (this.options.returnTokensAfterResetPassword) { return this.server.loginWithUser(user, infos); } return null; } /** * @description Change the password for a user. * @param {string} userId - User id. * @param {string} newPassword - A new password for the user. * @returns {Promise<void>} - Return a Promise. */ public async setPassword(userId: string, newPassword: string): Promise<void> { const password = await this.options.hashPassword(newPassword); return this.db.setPassword(userId, password); } /** * @description Change the current user's password. * It will trigger the `validatePassword` option and throw if password is invalid. * @param {string} userId - User id. * @param {string} oldPassword - The user's current password. * @param {string} newPassword - A new password for the user. * @returns {Promise<void>} - Return a Promise. * @throws {@link ChangePasswordErrors} */ public async changePassword( userId: string, oldPassword: string, newPassword: string ): Promise<void> { const user = await this.passwordAuthenticator({ id: userId }, oldPassword); if (!(await this.options.validatePassword(newPassword, user))) { throw new AccountsJsError( this.options.errors.invalidPassword, ChangePasswordErrors.InvalidPassword ); } const password = await this.options.hashPassword(newPassword); await this.db.setPassword(userId, password); await this.server.getHooks().emit(ServerHooks.ChangePasswordSuccess, user); if (this.options.invalidateAllSessionsAfterPasswordChanged) { await this.dbSessions.invalidateAllSessions(user.id); } if (this.options.removeAllResetPasswordTokensAfterPasswordChanged) { await this.db.removeAllResetPasswordTokens(user.id); } if (this.options.notifyUserAfterPasswordChanged) { const address = user.emails && user.emails[0].address; if (!address) { throw new AccountsJsError(this.options.errors.noEmailSet, ChangePasswordErrors.NoEmailSet); } const passwordChangedMail = this.server.prepareMail( address, '', this.server.sanitizeUser(user), '', this.server.options.emailTemplates.passwordChanged, this.server.options.emailTemplates.from ); await this.server.options.sendMail(passwordChangedMail); } } /** * @description Send an email with a link the user can use verify their email address. * @param {string} [address] - Which address of the user's to send the email to. * This address must be in the user's emails list. * Defaults to the first unverified email in the list. * If the address is already verified we do not send any email. * @returns {Promise<void>} - Return a Promise. * @throws {@link SendVerificationEmailErrors} */ public async sendVerificationEmail(address: string): Promise<void> { if (!address || !isString(address)) { throw new AccountsJsError( this.options.errors.invalidEmail, SendVerificationEmailErrors.InvalidEmail ); } const user = await this.db.findUserByEmail(address); if (!user) { throw new AccountsJsError( this.options.errors.userNotFound, SendVerificationEmailErrors.UserNotFound ); } // Do not send an email if the address is already verified const emailRecord = user.emails?.find( (email: EmailRecord) => email.address.toLowerCase() === address.toLocaleLowerCase() ); if (!emailRecord || emailRecord.verified) { return; } const token = generateRandomToken(); await this.db.addEmailVerificationToken(user.id, address, token); const resetPasswordMail = this.server.prepareMail( address, token, this.server.sanitizeUser(user), 'verify-email', this.server.options.emailTemplates.verifyEmail, this.server.options.emailTemplates.from ); await this.server.options.sendMail(resetPasswordMail); } /** * @description Send an email with a link the user can use to reset their password. * @param {string} [address] - Which address of the user's to send the email to. * This address must be in the user's emails list. * Defaults to the first email in the list. * @returns {Promise<void>} - Return a Promise. * @throws {@link SendResetPasswordEmailErrors} */ public async sendResetPasswordEmail(address: string): Promise<void> { if (!address || !isString(address)) { throw new AccountsJsError( this.options.errors.invalidEmail, SendResetPasswordEmailErrors.InvalidEmail ); } const user = await this.db.findUserByEmail(address); if (!user) { throw new AccountsJsError( this.options.errors.userNotFound, SendResetPasswordEmailErrors.UserNotFound ); } const token = generateRandomToken(); await this.db.addResetPasswordToken(user.id, address, token, 'reset'); const resetPasswordMail = this.server.prepareMail( address, token, this.server.sanitizeUser(user), 'reset-password', this.server.options.emailTemplates.resetPassword, this.server.options.emailTemplates.from ); await this.server.options.sendMail(resetPasswordMail); } /** * @description Send an email with a link the user can use to set their initial password. * The user's email will be verified after clicking on the link. * @param {string} [address] - Which address of the user's to send the email to. * This address must be in the user's emails list. * Defaults to the first email in the list. * @returns {Promise<void>} - Return a Promise. * @throws {@link SendEnrollmentEmailErrors} */ public async sendEnrollmentEmail(address: string): Promise<void> { if (!address || !isString(address)) { throw new AccountsJsError( this.options.errors.invalidEmail, SendEnrollmentEmailErrors.InvalidEmail ); } const user = await this.db.findUserByEmail(address); if (!user) { throw new AccountsJsError( this.options.errors.userNotFound, SendEnrollmentEmailErrors.UserNotFound ); } const token = generateRandomToken(); await this.db.addResetPasswordToken(user.id, address, token, 'enroll'); const enrollmentMail = this.server.prepareMail( address, token, this.server.sanitizeUser(user), 'enroll-account', this.server.options.emailTemplates.enrollAccount, this.server.options.emailTemplates.from ); await this.server.options.sendMail(enrollmentMail); } /** * @description Create a new user. * @param user - The user object. * @returns Return the id of user created. * @throws {@link CreateUserErrors} */ public async createUser(user: CreateUserServicePassword): Promise<string> { if (!user.username && !user.email) { throw new AccountsJsError( this.options.errors.usernameOrEmailRequired, CreateUserErrors.UsernameOrEmailRequired ); } if (user.username && !this.options.validateUsername(user.username)) { throw new AccountsJsError( this.options.errors.invalidUsername, CreateUserErrors.InvalidUsername ); } if (user.email && !this.options.validateEmail(user.email)) { throw new AccountsJsError(this.options.errors.invalidEmail, CreateUserErrors.InvalidEmail); } if (user.username && (await this.db.findUserByUsername(user.username))) { throw new AccountsJsError( this.options.errors.usernameAlreadyExists, CreateUserErrors.UsernameAlreadyExists ); } if (user.email && (await this.db.findUserByEmail(user.email))) { throw new AccountsJsError( this.options.errors.emailAlreadyExists, CreateUserErrors.EmailAlreadyExists ); } if (user.password) { if (!(await this.options.validatePassword(user.password))) { throw new AccountsJsError( this.options.errors.invalidPassword, CreateUserErrors.InvalidPassword ); } user.password = await this.options.hashPassword(user.password); } user = await this.options.validateNewUser(user); try { const userId = await this.db.createUser(user); const userRecord = (await this.db.findUserById(userId)) as User; await this.server.getHooks().emit(ServerHooks.CreateUserSuccess, userRecord); if (this.options.sendVerificationEmailAfterSignup && user.email) { await this.sendVerificationEmail(user.email); } return userId; } catch (e) { await this.server.getHooks().emit(ServerHooks.CreateUserError, user); throw e; } } public isTokenExpired(tokenRecord: TokenRecord, expiryDate: number): boolean { return Number(tokenRecord.when) + expiryDate < Date.now(); } private async passwordAuthenticator( user: string | LoginUserIdentity, password: string ): Promise<CustomUser> { const { username, email, id } = isString(user) ? this.toUsernameAndEmail({ user }) : this.toUsernameAndEmail({ ...user }); let foundUser: CustomUser | null = null; if (id) { // this._validateLoginWithField('id', user); foundUser = await this.db.findUserById(id); } else if (username) { // this._validateLoginWithField('username', user); foundUser = await this.db.findUserByUsername(username); } else if (email) { // this._validateLoginWithField('email', user); foundUser = await this.db.findUserByEmail(email); } if (!foundUser) { if (this.server.options.ambiguousErrorMessages) { throw new AccountsJsError( this.options.errors.invalidCredentials, PasswordAuthenticatorErrors.InvalidCredentials ); } else { throw new AccountsJsError( this.options.errors.userNotFound, PasswordAuthenticatorErrors.UserNotFound ); } } if (this.options.requireEmailVerification) { // If the user logs in using the email it must be a verified address, if he provided an username at least one of the associated emails must be verified. if ( !foundUser.emails?.find(({ address, verified }) => email ? address === email && verified : verified ) ) { throw new AccountsJsError( this.options.errors.emailNotVerified, PasswordAuthenticatorErrors.EmailNotVerified ); } } const hash = await this.db.findPasswordHash(foundUser.id); if (!hash) { throw new AccountsJsError( this.options.errors.noPasswordSet, PasswordAuthenticatorErrors.NoPasswordSet ); } const isPasswordValid = await this.options.verifyPassword(password, hash); if (!isPasswordValid) { if (this.server.options.ambiguousErrorMessages) { throw new AccountsJsError( this.options.errors.invalidCredentials, PasswordAuthenticatorErrors.InvalidCredentials ); } else { throw new AccountsJsError( this.options.errors.incorrectPassword, PasswordAuthenticatorErrors.IncorrectPassword ); } } return foundUser; } /** * Given a username, user and/or email figure out the username and/or email. * * @param user An object containing at least `username`, `user` and/or `email`. * @returns An object containing `id`, `username` and `email`. */ private toUsernameAndEmail({ user, username, email, id }: any): any { if (user && !username && !email) { if (isEmail(user)) { email = user; username = null; } else { username = user; email = null; } } return { username, email, id }; } }