From 4368c1847970defe663242b3df6970644a9ed662 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 26 Apr 2024 12:21:40 +0200 Subject: [PATCH] feat: User email verification after signing-up. --- .../src/api/controllers/Authentication.ts | 79 ++++ .../server/src/interfaces/Authentication.ts | 21 +- packages/server/src/loaders/eventEmitter.ts | 4 +- packages/server/src/loaders/jobs.ts | 2 + .../Authentication/AuthApplication.ts | 33 ++ .../src/services/Authentication/AuthSignup.ts | 3 + .../Authentication/AuthSignupConfirm.ts | 57 +++ .../Authentication/AuthSignupResend.ts | 35 ++ .../AuthenticationMailMessages.ts | 30 ++ .../src/services/Authentication/_constants.ts | 2 + .../events/SendVerfiyMailOnSignUp.ts | 30 ++ .../Authentication/jobs/SendVerifyMailJob.ts | 35 ++ packages/server/src/subscribers/events.ts | 3 + ...00821_add_confirmation_columns_to_users.js | 8 + .../server/src/system/models/SystemUser.ts | 20 +- .../server/views/mail/SignupVerifyEmail.html | 424 ++++++++++++++++++ 16 files changed, 778 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/services/Authentication/AuthSignupConfirm.ts create mode 100644 packages/server/src/services/Authentication/AuthSignupResend.ts create mode 100644 packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts create mode 100644 packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts create mode 100644 packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js create mode 100644 packages/server/views/mail/SignupVerifyEmail.html diff --git a/packages/server/src/api/controllers/Authentication.ts b/packages/server/src/api/controllers/Authentication.ts index aae43f4b4..c162508ba 100644 --- a/packages/server/src/api/controllers/Authentication.ts +++ b/packages/server/src/api/controllers/Authentication.ts @@ -28,6 +28,20 @@ export default class AuthenticationController extends BaseController { asyncMiddleware(this.login.bind(this)), this.handlerErrors ); + router.post( + 'register/verify/resend', + [check('email').exists().isEmail()], + this.validationResult, + asyncMiddleware(this.registerVerifyResendMail.bind(this)), + this.handlerErrors + ); + router.post( + '/register/verify', + this.signupVerifySchema, + this.validationResult, + asyncMiddleware(this.registerVerify.bind(this)), + this.handlerErrors + ); router.post( '/register', this.registerSchema, @@ -99,6 +113,17 @@ export default class AuthenticationController extends BaseController { ]; } + private get signupVerifySchema(): ValidationChain[] { + return [ + check('email') + .exists() + .isString() + .isEmail() + .isLength({ max: DATATYPES_LENGTH.STRING }), + check('token').exists().isString(), + ]; + } + /** * Reset password schema. * @returns {ValidationChain[]} @@ -166,6 +191,60 @@ export default class AuthenticationController extends BaseController { } } + /** + * Verifies the provider user's email after signin-up. + * @param {Request} req + * @param {Response}| res + * @param {Function} next + * @returns {Response|void} + */ + private async registerVerify(req: Request, res: Response, next: Function) { + const signUpVerifyDTO = this.matchedBodyData(req); + + try { + const user = await this.authApplication.signUpConfirm( + signUpVerifyDTO.email, + signUpVerifyDTO.token + ); + return res.status(200).send({ + type: 'success', + message: 'The given user has verified successfully', + user, + }); + } catch (error) { + next(error); + } + } + + /** + * + * @param {Request} req + * @param {Response}| res + * @param {Function} next + * @returns + */ + private async registerVerifyResendMail( + req: Request, + res: Response, + next: Function + ) { + const signUpVerifyDTO = this.matchedBodyData(req); + + try { + const user = await this.authApplication.signUpConfirm( + signUpVerifyDTO.email, + signUpVerifyDTO.token + ); + return res.status(200).send({ + type: 'success', + message: 'The given user has verified successfully', + user, + }); + } catch (error) { + next(error); + } + } + /** * Send reset password handler * @param {Request} req diff --git a/packages/server/src/interfaces/Authentication.ts b/packages/server/src/interfaces/Authentication.ts index 253c178f9..af29d0b5f 100644 --- a/packages/server/src/interfaces/Authentication.ts +++ b/packages/server/src/interfaces/Authentication.ts @@ -66,16 +66,27 @@ export interface IAuthResetedPasswordEventPayload { password: string; } - export interface IAuthSendingResetPassword { - user: ISystemUser, + user: ISystemUser; token: string; } export interface IAuthSendedResetPassword { - user: ISystemUser, + user: ISystemUser; token: string; } -export interface IAuthGetMetaPOJO { +export interface IAuthGetMetaPOJO { signupDisabled: boolean; -} \ No newline at end of file +} + +export interface IAuthSignUpVerifingEventPayload { + email: string; + verifyToken: string; + userId: number; +} + +export interface IAuthSignUpVerifiedEventPayload { + email: string; + verifyToken: string; + userId: number; +} diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 91d814f8a..9da52fd86 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -91,6 +91,7 @@ import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/s import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize'; import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete'; import { SubscribeFreeOnSignupCommunity } from '@/services/Subscription/events/SubscribeFreeOnSignupCommunity'; +import { SendVerfiyMailOnSignUp } from '@/services/Authentication/events/SendVerfiyMailOnSignUp'; export default () => { @@ -222,6 +223,7 @@ export const susbcribers = () => { DeleteCashflowTransactionOnUncategorize, PreventDeleteTransactionOnDelete, - SubscribeFreeOnSignupCommunity + SubscribeFreeOnSignupCommunity, + SendVerfiyMailOnSignUp ]; }; diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 58da23291..231149f48 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -12,6 +12,7 @@ import { SaleReceiptMailNotificationJob } from '@/services/Sales/Receipts/SaleRe import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob'; import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob'; import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDeleteExpiredFilesJob'; +import { SendVerifyMailJob } from '@/services/Authentication/jobs/SendVerifyMailJob'; export default ({ agenda }: { agenda: Agenda }) => { new ResetPasswordMailJob(agenda); @@ -27,6 +28,7 @@ export default ({ agenda }: { agenda: Agenda }) => { new PaymentReceiveMailNotificationJob(agenda); new PlaidFetchTransactionsJob(agenda); new ImportDeleteExpiredFilesJobs(agenda); + new SendVerifyMailJob(agenda); agenda.start().then(() => { agenda.every('1 hours', 'delete-expired-imported-files', {}); diff --git a/packages/server/src/services/Authentication/AuthApplication.ts b/packages/server/src/services/Authentication/AuthApplication.ts index 9fa74c973..bbc547f1f 100644 --- a/packages/server/src/services/Authentication/AuthApplication.ts +++ b/packages/server/src/services/Authentication/AuthApplication.ts @@ -9,6 +9,13 @@ import { AuthSigninService } from './AuthSignin'; import { AuthSignupService } from './AuthSignup'; import { AuthSendResetPassword } from './AuthSendResetPassword'; import { GetAuthMeta } from './GetAuthMeta'; +import { AuthSignupConfirmService } from './AuthSignupConfirm'; +import { SystemUser } from '@/system/models'; + +interface ISignupConfirmDTO { + token: string; + email: string; +} @Service() export default class AuthenticationApplication { @@ -18,6 +25,9 @@ export default class AuthenticationApplication { @Inject() private authSignupService: AuthSignupService; + @Inject() + private authSignupConfirmService: AuthSignupConfirmService; + @Inject() private authResetPasswordService: AuthSendResetPassword; @@ -44,6 +54,29 @@ export default class AuthenticationApplication { return this.authSignupService.signUp(signupDTO); } + /** + * Verfying the provided user's email after signin-up. + * @param {string} email + * @param {string} token + * @returns {Promise} + */ + public async signUpConfirm( + email: string, + token: string + ): Promise { + return this.authSignupConfirmService.signUpConfirm(email, token); + } + + /** + * + * @param {string} email + * @param {string} token + * @returns + */ + public async signUpConfirmSend(email: string, token: string) { + return this.authSignupConfirmService.signUpConfirm(email, token); + } + /** * Generates and retrieve password reset token for the given user email. * @param {string} email diff --git a/packages/server/src/services/Authentication/AuthSignup.ts b/packages/server/src/services/Authentication/AuthSignup.ts index b064a3d91..2be8c63a2 100644 --- a/packages/server/src/services/Authentication/AuthSignup.ts +++ b/packages/server/src/services/Authentication/AuthSignup.ts @@ -1,5 +1,6 @@ import { isEmpty, omit } from 'lodash'; import moment from 'moment'; +import crypto from 'crypto'; import { ServiceError } from '@/exceptions'; import { IAuthSignedUpEventPayload, @@ -41,6 +42,7 @@ export class AuthSignupService { await this.validateEmailUniqiness(signupDTO.email); const hashedPassword = await hashPassword(signupDTO.password); + const verifyToken = crypto.randomBytes(64).toString('hex'); // Triggers signin up event. await this.eventPublisher.emitAsync(events.auth.signingUp, { @@ -50,6 +52,7 @@ export class AuthSignupService { const tenant = await this.tenantsManager.createTenant(); const registeredUser = await systemUserRepository.create({ ...omit(signupDTO, 'country'), + verifyToken, active: true, password: hashedPassword, tenantId: tenant.id, diff --git a/packages/server/src/services/Authentication/AuthSignupConfirm.ts b/packages/server/src/services/Authentication/AuthSignupConfirm.ts new file mode 100644 index 000000000..940b6ae7f --- /dev/null +++ b/packages/server/src/services/Authentication/AuthSignupConfirm.ts @@ -0,0 +1,57 @@ +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { SystemUser } from '@/system/models'; +import { ERRORS } from './_constants'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { + IAuthSignUpVerifiedEventPayload, + IAuthSignUpVerifingEventPayload, +} from '@/interfaces'; + +@Service() +export class AuthSignupConfirmService { + @Inject() + private eventPublisher: EventPublisher; + + /** + * Verifies the provided user's email after signing-up. + * @throws {ServiceErrors} + * @param {IRegisterDTO} signupDTO + * @returns {Promise} + */ + public async signUpConfirm( + email: string, + verifyToken: string + ): Promise { + const foundUser = await SystemUser.query().findOne({ email, verifyToken }); + + if (!foundUser) { + throw new ServiceError(ERRORS.SIGNUP_CONFIRM_TOKEN_INVALID); + } + const userId = foundUser.id; + + // Triggers `signUpConfirming` event. + await this.eventPublisher.emitAsync(events.auth.signUpConfirming, { + email, + verifyToken, + userId, + } as IAuthSignUpVerifingEventPayload); + + const updatedUser = await SystemUser.query().patchAndFetchById( + foundUser.id, + { + verified: true, + verifyToken: '', + } + ); + // Triggers `signUpConfirmed` event. + await this.eventPublisher.emitAsync(events.auth.signUpConfirmed, { + email, + verifyToken, + userId, + } as IAuthSignUpVerifiedEventPayload); + + return updatedUser; + } +} diff --git a/packages/server/src/services/Authentication/AuthSignupResend.ts b/packages/server/src/services/Authentication/AuthSignupResend.ts new file mode 100644 index 000000000..581a20236 --- /dev/null +++ b/packages/server/src/services/Authentication/AuthSignupResend.ts @@ -0,0 +1,35 @@ +import { ServiceError } from '@/exceptions'; +import { SystemUser } from '@/system/models'; +import { Inject, Service } from 'typedi'; +import { ERRORS } from './_constants'; + +@Service() +export class AuthSignupConfirmResend { + @Inject('agenda') + private agenda: any; + + /** + * + * @param {number} tenantId + * @param {string} email + */ + public async signUpConfirmResend(email: string) { + const user = await SystemUser.query() + .findOne({ email }) + .throwIfNotFound(); + + // + if (user.verified) { + throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED) + } + if (user.verifyToken) { + throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED); + } + const payload = { + email: user.email, + token: user.verifyToken, + fullName: user.firstName, + }; + await this.agenda.now('send-signup-verify-mail', payload); + } +} diff --git a/packages/server/src/services/Authentication/AuthenticationMailMessages.ts b/packages/server/src/services/Authentication/AuthenticationMailMessages.ts index c8c26ea0d..e974d22b9 100644 --- a/packages/server/src/services/Authentication/AuthenticationMailMessages.ts +++ b/packages/server/src/services/Authentication/AuthenticationMailMessages.ts @@ -33,4 +33,34 @@ export default class AuthenticationMailMesssages { }) .send(); } + + /** + * Sends signup verification mail. + * @param {string} email - Email address + * @param {string} fullName - User name. + * @param {string} token - Verification token. + * @returns {Promise} + */ + public async sendSignupVerificationMail( + email: string, + fullName: string, + token: string, + ) { + await new Mail() + .setSubject('Bigcapital - Verify your email') + .setView('mail/SignupVerifyEmail.html') + .setTo(email) + .setAttachments([ + { + filename: 'bigcapital.png', + path: `${global.__views_dir}/images/bigcapital.png`, + cid: 'bigcapital_logo', + }, + ]) + .setData({ + verifyUrl: `${config.baseURL}/auth/reset_password/${token}`, + fullName, + }) + .send(); + } } diff --git a/packages/server/src/services/Authentication/_constants.ts b/packages/server/src/services/Authentication/_constants.ts index 8c62dbe6c..506525779 100644 --- a/packages/server/src/services/Authentication/_constants.ts +++ b/packages/server/src/services/Authentication/_constants.ts @@ -9,4 +9,6 @@ export const ERRORS = { EMAIL_EXISTS: 'EMAIL_EXISTS', SIGNUP_RESTRICTED_NOT_ALLOWED: 'SIGNUP_RESTRICTED_NOT_ALLOWED', SIGNUP_RESTRICTED: 'SIGNUP_RESTRICTED', + SIGNUP_CONFIRM_TOKEN_INVALID: 'SIGNUP_CONFIRM_TOKEN_INVALID', + USER_ALREADY_VERIFIED: 'USER_ALREADY_VERIFIED', }; diff --git a/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts b/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts new file mode 100644 index 000000000..14f9aaa07 --- /dev/null +++ b/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts @@ -0,0 +1,30 @@ +import { IAuthSignedUpEventPayload } from '@/interfaces'; +import events from '@/subscribers/events'; +import { Inject } from 'typedi'; + +export class SendVerfiyMailOnSignUp { + @Inject('agenda') + private agenda: any; + + /** + * Attaches events with handles. + */ + public attach(bus) { + bus.subscribe(events.auth.signUp, this.handleSendVerifyMailOnSignup); + } + + /** + * + * @param {ITaxRateEditedPayload} payload - + */ + private handleSendVerifyMailOnSignup = async ({ + user, + }: IAuthSignedUpEventPayload) => { + const payload = { + email: user.email, + token: user.verifyToken, + fullName: user.firstName, + }; + await this.agenda.now('send-signup-verify-mail', payload); + }; +} diff --git a/packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts b/packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts new file mode 100644 index 000000000..8e09053da --- /dev/null +++ b/packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts @@ -0,0 +1,35 @@ +import { Container } from 'typedi'; +import AuthenticationMailMesssages from '@/services/Authentication/AuthenticationMailMessages'; + +export class SendVerifyMailJob { + /** + * Constructor method. + * @param {Agenda} agenda + */ + constructor(agenda) { + agenda.define( + 'send-signup-verify-mail', + { priority: 'high' }, + this.handler.bind(this) + ); + } + + /** + * Handle send welcome mail job. + * @param {Job} job + * @param {Function} done + */ + public async handler(job, done: Function): Promise { + const { data } = job.attrs; + const { email, fullName, token } = data; + const authService = Container.get(AuthenticationMailMesssages); + + try { + await authService.sendSignupVerificationMail(email, fullName, token); + done(); + } catch (error) { + console.log(error); + done(error); + } + } +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 0243cd172..b96cadf95 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -9,6 +9,9 @@ export default { signUp: 'onSignUp', signingUp: 'onSigningUp', + signUpConfirming: 'signUpConfirming', + signUpConfirmed: 'signUpConfirmed', + sendingResetPassword: 'onSendingResetPassword', sendResetPassword: 'onSendResetPassword', diff --git a/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js b/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js new file mode 100644 index 000000000..125f508f5 --- /dev/null +++ b/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js @@ -0,0 +1,8 @@ +exports.up = function (knex) { + return knex.schema.table('users', (table) => { + table.string('verify_token'); + table.boolean('verified').defaultTo(false); + }); +}; + +exports.down = (knex) => {}; diff --git a/packages/server/src/system/models/SystemUser.ts b/packages/server/src/system/models/SystemUser.ts index a341ccedf..627caaeb6 100644 --- a/packages/server/src/system/models/SystemUser.ts +++ b/packages/server/src/system/models/SystemUser.ts @@ -4,6 +4,12 @@ import SystemModel from '@/system/models/SystemModel'; import SoftDeleteQueryBuilder from '@/collection/SoftDeleteQueryBuilder'; export default class SystemUser extends SystemModel { + firstName!: string; + lastName!: string; + verified!: boolean; + inviteAcceptedAt!: Date | null; + deletedAt!: Date | null; + /** * Table name. */ @@ -33,19 +39,29 @@ export default class SystemUser extends SystemModel { } /** - * + * Detarmines whether the user is deleted. + * @returns {boolean} */ get isDeleted() { return !!this.deletedAt; } /** - * + * Detarmines whether the sent invite is accepted. + * @returns {boolean} */ get isInviteAccepted() { return !!this.inviteAcceptedAt; } + /** + * Detarmines whether the user's email is verified. + * @returns {boolean} + */ + get isVerified() { + return !!this.verified; + } + /** * Full name attribute. */ diff --git a/packages/server/views/mail/SignupVerifyEmail.html b/packages/server/views/mail/SignupVerifyEmail.html new file mode 100644 index 000000000..637e78c12 --- /dev/null +++ b/packages/server/views/mail/SignupVerifyEmail.html @@ -0,0 +1,424 @@ + + + + + + Bigcapital | Reset your password + + + + Verify your email. + + + + + + + + +