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

459 lines 23.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const two_factor_1 = require("@accounts/two-factor"); const server_1 = require("@accounts/server"); const utils_1 = require("./utils"); const types_1 = require("./types"); const errors_1 = require("./errors"); const validation_1 = require("./utils/validation"); const graphql_modules_1 = require("graphql-modules"); 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: errors_1.errors, sendVerificationEmailAfterSignup: false, validateEmail(email) { return (0, validation_1.isString)(email) && (0, utils_1.isEmail)(email); }, validatePassword(password) { return (0, validation_1.isString)(password) && password !== ''; }, validateUsername(username) { const usernameRegex = /^[a-zA-Z][a-zA-Z0-9]*$/; return (0, validation_1.isString)(username) && usernameRegex.test(username); }, // If user does not provide the validateNewUser function only allow some fields validateNewUser(user) { const safeUser = { password: user.password }; if (user.username) { safeUser.username = user.username; } if (user.email) { safeUser.email = user.email; } return safeUser; }, hashPassword: utils_1.bcryptPassword, verifyPassword: utils_1.verifyPassword, }; let AccountsPassword = class AccountsPassword { serviceName = 'password'; server; twoFactor; options; db; dbSessions; constructor(options = {}, db, dbSessions, server) { this.options = { ...defaultOptions, ...options }; 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 two_factor_1.TwoFactor(options.twoFactor); if (db) { this.db = db; this.dbSessions = dbSessions ?? db; } if (server) { this.server = server; } } setUserStore(store) { this.db = store; this.twoFactor.setUserStore(store); } setSessionsStore(store) { this.dbSessions = store ?? this.db; } async authenticate(params) { const { user, password, code } = params; if (!user || !password) { throw new server_1.AccountsJsError(this.options.errors.unrecognizedOptionsForLogin, errors_1.AuthenticateErrors.UnrecognizedOptionsForLogin); } if ((!(0, validation_1.isString)(user) && !(0, validation_1.isObject)(user)) || !(0, validation_1.isString)(password)) { throw new server_1.AccountsJsError(this.options.errors.matchFailed, errors_1.AuthenticateErrors.MatchFailed); } const foundUser = await this.passwordAuthenticator(user, password); // If user activated two factor authentication try with the code if ((0, two_factor_1.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. */ findUserByEmail(email) { 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. */ findUserByUsername(username) { 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} */ addEmail(userId, newEmail, verified = false) { if (!this.options.validateEmail(newEmail)) { throw new server_1.AccountsJsError(this.options.errors.invalidEmail, errors_1.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. */ removeEmail(userId, email) { 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} */ async verifyEmail(token) { if (!token || !(0, validation_1.isString)(token)) { throw new server_1.AccountsJsError(this.options.errors.invalidToken, errors_1.VerifyEmailErrors.InvalidToken); } const user = await this.db.findUserByEmailVerificationToken(token); if (!user) { throw new server_1.AccountsJsError(this.options.errors.verifyEmailLinkExpired, errors_1.VerifyEmailErrors.VerifyEmailLinkExpired); } const verificationTokens = (0, utils_1.getUserVerificationTokens)(user); const tokenRecord = verificationTokens.find((t) => t.token === token); if (!tokenRecord || this.isTokenExpired(tokenRecord, this.options.verifyEmailTokenExpiration)) { throw new server_1.AccountsJsError(this.options.errors.verifyEmailLinkExpired, errors_1.VerifyEmailErrors.VerifyEmailLinkExpired); } const emailRecord = user.emails?.find((e) => e.address === tokenRecord.address); if (!emailRecord) { throw new server_1.AccountsJsError(this.options.errors.verifyEmailLinkUnknownAddress, errors_1.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} */ async resetPassword(token, newPassword, infos) { if (!token || !(0, validation_1.isString)(token)) { throw new server_1.AccountsJsError(this.options.errors.invalidToken, errors_1.ResetPasswordErrors.InvalidToken); } if (!this.options.validatePassword(newPassword)) { throw new server_1.AccountsJsError(this.options.errors.invalidNewPassword, errors_1.ResetPasswordErrors.InvalidNewPassword); } const user = await this.db.findUserByResetPasswordToken(token); if (!user) { throw new server_1.AccountsJsError(this.options.errors.resetPasswordLinkExpired, errors_1.ResetPasswordErrors.ResetPasswordLinkExpired); } const resetTokens = (0, utils_1.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 server_1.AccountsJsError(this.options.errors.resetPasswordLinkExpired, errors_1.ResetPasswordErrors.ResetPasswordLinkExpired); } const emails = user.emails || []; if (!emails.map((email) => email.address).includes(resetTokenRecord.address)) { throw new server_1.AccountsJsError(this.options.errors.resetPasswordLinkUnknownAddress, errors_1.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(server_1.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 server_1.AccountsJsError(this.options.errors.noEmailSet, errors_1.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. */ async setPassword(userId, newPassword) { 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} */ async changePassword(userId, oldPassword, newPassword) { if (!this.options.validatePassword(newPassword)) { throw new server_1.AccountsJsError(this.options.errors.invalidPassword, errors_1.ChangePasswordErrors.InvalidPassword); } const user = await this.passwordAuthenticator({ id: userId }, oldPassword); const password = await this.options.hashPassword(newPassword); await this.db.setPassword(userId, password); await this.server.getHooks().emit(server_1.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 server_1.AccountsJsError(this.options.errors.noEmailSet, errors_1.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} */ async sendVerificationEmail(address) { if (!address || !(0, validation_1.isString)(address)) { throw new server_1.AccountsJsError(this.options.errors.invalidEmail, errors_1.SendVerificationEmailErrors.InvalidEmail); } const user = await this.db.findUserByEmail(address); if (!user) { throw new server_1.AccountsJsError(this.options.errors.userNotFound, errors_1.SendVerificationEmailErrors.UserNotFound); } // Do not send an email if the address is already verified const emailRecord = user.emails?.find((email) => email.address.toLowerCase() === address.toLocaleLowerCase()); if (!emailRecord || emailRecord.verified) { return; } const token = (0, server_1.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} */ async sendResetPasswordEmail(address) { if (!address || !(0, validation_1.isString)(address)) { throw new server_1.AccountsJsError(this.options.errors.invalidEmail, errors_1.SendResetPasswordEmailErrors.InvalidEmail); } const user = await this.db.findUserByEmail(address); if (!user) { throw new server_1.AccountsJsError(this.options.errors.userNotFound, errors_1.SendResetPasswordEmailErrors.UserNotFound); } const token = (0, server_1.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} */ async sendEnrollmentEmail(address) { if (!address || !(0, validation_1.isString)(address)) { throw new server_1.AccountsJsError(this.options.errors.invalidEmail, errors_1.SendEnrollmentEmailErrors.InvalidEmail); } const user = await this.db.findUserByEmail(address); if (!user) { throw new server_1.AccountsJsError(this.options.errors.userNotFound, errors_1.SendEnrollmentEmailErrors.UserNotFound); } const token = (0, server_1.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} */ async createUser(user) { if (!user.username && !user.email) { throw new server_1.AccountsJsError(this.options.errors.usernameOrEmailRequired, errors_1.CreateUserErrors.UsernameOrEmailRequired); } if (user.username && !this.options.validateUsername(user.username)) { throw new server_1.AccountsJsError(this.options.errors.invalidUsername, errors_1.CreateUserErrors.InvalidUsername); } if (user.email && !this.options.validateEmail(user.email)) { throw new server_1.AccountsJsError(this.options.errors.invalidEmail, errors_1.CreateUserErrors.InvalidEmail); } if (user.username && (await this.db.findUserByUsername(user.username))) { throw new server_1.AccountsJsError(this.options.errors.usernameAlreadyExists, errors_1.CreateUserErrors.UsernameAlreadyExists); } if (user.email && (await this.db.findUserByEmail(user.email))) { throw new server_1.AccountsJsError(this.options.errors.emailAlreadyExists, errors_1.CreateUserErrors.EmailAlreadyExists); } if (user.password) { if (!this.options.validatePassword(user.password)) { throw new server_1.AccountsJsError(this.options.errors.invalidPassword, errors_1.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)); await this.server.getHooks().emit(server_1.ServerHooks.CreateUserSuccess, userRecord); if (this.options.sendVerificationEmailAfterSignup && user.email) { await this.sendVerificationEmail(user.email); } return userId; } catch (e) { await this.server.getHooks().emit(server_1.ServerHooks.CreateUserError, user); throw e; } } isTokenExpired(tokenRecord, expiryDate) { return Number(tokenRecord.when) + expiryDate < Date.now(); } async passwordAuthenticator(user, password) { const { username, email, id } = (0, validation_1.isString)(user) ? this.toUsernameAndEmail({ user }) : this.toUsernameAndEmail({ ...user }); let foundUser = 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 server_1.AccountsJsError(this.options.errors.invalidCredentials, errors_1.PasswordAuthenticatorErrors.InvalidCredentials); } else { throw new server_1.AccountsJsError(this.options.errors.userNotFound, errors_1.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 server_1.AccountsJsError(this.options.errors.emailNotVerified, errors_1.PasswordAuthenticatorErrors.EmailNotVerified); } } const hash = await this.db.findPasswordHash(foundUser.id); if (!hash) { throw new server_1.AccountsJsError(this.options.errors.noPasswordSet, errors_1.PasswordAuthenticatorErrors.NoPasswordSet); } const isPasswordValid = await this.options.verifyPassword(password, hash); if (!isPasswordValid) { if (this.server.options.ambiguousErrorMessages) { throw new server_1.AccountsJsError(this.options.errors.invalidCredentials, errors_1.PasswordAuthenticatorErrors.InvalidCredentials); } else { throw new server_1.AccountsJsError(this.options.errors.incorrectPassword, errors_1.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`. */ toUsernameAndEmail({ user, username, email, id }) { if (user && !username && !email) { if ((0, utils_1.isEmail)(user)) { email = user; username = null; } else { username = user; email = null; } } return { username, email, id }; } }; AccountsPassword = tslib_1.__decorate([ (0, graphql_modules_1.Injectable)({ global: true, }), tslib_1.__param(0, (0, graphql_modules_1.Inject)(types_1.AccountsPasswordConfigToken)), tslib_1.__param(1, (0, graphql_modules_1.Inject)(server_1.DatabaseInterfaceUserToken)), tslib_1.__param(2, (0, graphql_modules_1.Inject)(server_1.DatabaseInterfaceSessionsToken)), tslib_1.__param(3, (0, graphql_modules_1.Inject)(server_1.AccountsServer)), tslib_1.__metadata("design:paramtypes", [Object, Object, Object, server_1.AccountsServer]) ], AccountsPassword); exports.default = AccountsPassword; //# sourceMappingURL=accounts-password.js.map