Merge pull request #426 from bigcapitalhq/big-163-user-email-verification-after-signing-up

feat: User email verification after signing-up.
This commit is contained in:
Ahmed Bouhuolia
2024-05-06 17:46:26 +02:00
committed by GitHub
38 changed files with 1193 additions and 54 deletions

View File

@@ -9,6 +9,8 @@ import { DATATYPES_LENGTH } from '@/data/DataTypes';
import LoginThrottlerMiddleware from '@/api/middleware/LoginThrottlerMiddleware';
import AuthenticationApplication from '@/services/Authentication/AuthApplication';
import JWTAuth from '@/api/middleware/jwtAuth';
import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser';
@Service()
export default class AuthenticationController extends BaseController {
@Inject()
@@ -28,6 +30,20 @@ export default class AuthenticationController extends BaseController {
asyncMiddleware(this.login.bind(this)),
this.handlerErrors
);
router.use('/register/verify/resend', JWTAuth);
router.use('/register/verify/resend', AttachCurrentTenantUser);
router.post(
'/register/verify/resend',
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 +115,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 +193,58 @@ 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: { email: string; token: string } =
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);
}
}
/**
* Resends the confirmation email to the user.
* @param {Request} req
* @param {Response}| res
* @param {Function} next
*/
private async registerVerifyResendMail(
req: Request,
res: Response,
next: Function
) {
const { user } = req;
try {
const data = await this.authApplication.signUpConfirmResend(user.id);
return res.status(200).send({
type: 'success',
message: 'The given user has verified successfully',
data,
});
} catch (error) {
next(error);
}
}
/**
* Send reset password handler
* @param {Request} req

View File

@@ -153,6 +153,13 @@ module.exports = {
),
},
/**
* Sign-up email confirmation
*/
signupConfirmation: {
enabled: parseBoolean<boolean>(process.env.SIGNUP_EMAIL_CONFIRMATION, false),
},
/**
* Puppeteer remote browserless connection.
*/

View File

@@ -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;
}
}
export interface IAuthSignUpVerifingEventPayload {
email: string;
verifyToken: string;
userId: number;
}
export interface IAuthSignUpVerifiedEventPayload {
email: string;
verifyToken: string;
userId: number;
}

View File

@@ -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
];
};

View File

@@ -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', {});

View File

@@ -1,4 +1,4 @@
import { Service, Inject, Container } from 'typedi';
import { Service, Inject } from 'typedi';
import {
IRegisterDTO,
ISystemUser,
@@ -9,6 +9,9 @@ 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';
import { AuthSignupConfirmResend } from './AuthSignupResend';
@Service()
export default class AuthenticationApplication {
@@ -18,6 +21,12 @@ export default class AuthenticationApplication {
@Inject()
private authSignupService: AuthSignupService;
@Inject()
private authSignupConfirmService: AuthSignupConfirmService;
@Inject()
private authSignUpConfirmResendService: AuthSignupConfirmResend;
@Inject()
private authResetPasswordService: AuthSendResetPassword;
@@ -44,6 +53,28 @@ 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<SystemUser>}
*/
public async signUpConfirm(
email: string,
token: string
): Promise<SystemUser> {
return this.authSignupConfirmService.signUpConfirm(email, token);
}
/**
* Resends the confirmation email of the given system user.
* @param {number} userId - System user id.
* @returns {Promise<void>}
*/
public async signUpConfirmResend(userId: number) {
return this.authSignUpConfirmResendService.signUpConfirmResend(userId);
}
/**
* Generates and retrieve password reset token for the given user email.
* @param {string} email

View File

@@ -1,5 +1,6 @@
import { isEmpty, omit } from 'lodash';
import { defaultTo, isEmpty, omit } from 'lodash';
import moment from 'moment';
import crypto from 'crypto';
import { ServiceError } from '@/exceptions';
import {
IAuthSignedUpEventPayload,
@@ -42,6 +43,13 @@ export class AuthSignupService {
const hashedPassword = await hashPassword(signupDTO.password);
const verifyTokenCrypto = crypto.randomBytes(64).toString('hex');
const verifiedEnabed = defaultTo(config.signupConfirmation.enabled, false);
const verifyToken = verifiedEnabed ? verifyTokenCrypto : '';
const verified = !verifiedEnabed;
const inviteAcceptedAt = moment().format('YYYY-MM-DD');
// Triggers signin up event.
await this.eventPublisher.emitAsync(events.auth.signingUp, {
signupDTO,
@@ -50,10 +58,12 @@ export class AuthSignupService {
const tenant = await this.tenantsManager.createTenant();
const registeredUser = await systemUserRepository.create({
...omit(signupDTO, 'country'),
verifyToken,
verified,
active: true,
password: hashedPassword,
tenantId: tenant.id,
inviteAcceptedAt: moment().format('YYYY-MM-DD'),
inviteAcceptedAt,
});
// Triggers signed up event.
await this.eventPublisher.emitAsync(events.auth.signUp, {

View File

@@ -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<ISystemUser>}
*/
public async signUpConfirm(
email: string,
verifyToken: string
): Promise<SystemUser> {
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 as SystemUser;
}
}

View File

@@ -0,0 +1,34 @@
import { Inject, Service } from 'typedi';
import { ServiceError } from '@/exceptions';
import { SystemUser } from '@/system/models';
import { ERRORS } from './_constants';
@Service()
export class AuthSignupConfirmResend {
@Inject('agenda')
private agenda: any;
/**
* Resends the email confirmation of the given user.
* @param {number} userId - User ID.
* @returns {Promise<void>}
*/
public async signUpConfirmResend(userId: number) {
const user = await SystemUser.query().findById(userId).throwIfNotFound();
// Throw error if the user is already verified.
if (user.verified) {
throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED);
}
// Throw error if the verification token is not exist.
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);
}
}

View File

@@ -33,4 +33,33 @@ 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<void>}
*/
public async sendSignupVerificationMail(
email: string,
fullName: string,
token: string
) {
const verifyUrl = `${config.baseURL}/auth/email_confirmation?token=${token}&email=${email}`;
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, fullName })
.send();
}
}

View File

@@ -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',
};

View File

@@ -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);
};
}

View File

@@ -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<void> {
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);
}
}
}

View File

@@ -9,6 +9,9 @@ export default {
signUp: 'onSignUp',
signingUp: 'onSigningUp',
signUpConfirming: 'signUpConfirming',
signUpConfirmed: 'signUpConfirmed',
sendingResetPassword: 'onSendingResetPassword',
sendResetPassword: 'onSendResetPassword',

View File

@@ -0,0 +1,12 @@
exports.up = function (knex) {
return knex.schema
.table('users', (table) => {
table.string('verify_token');
table.boolean('verified').defaultTo(false);
})
.then(() => {
return knex('USERS').update({ verified: true });
});
};
exports.down = (knex) => {};

View File

@@ -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.
*/
@@ -29,23 +35,33 @@ export default class SystemUser extends SystemModel {
* Virtual attributes.
*/
static get virtualAttributes() {
return ['fullName', 'isDeleted', 'isInviteAccepted'];
return ['fullName', 'isDeleted', 'isInviteAccepted', 'isVerified'];
}
/**
*
* 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.
*/

View File

@@ -1,6 +1,6 @@
import bcrypt from 'bcryptjs';
import moment from 'moment';
import _ from 'lodash';
import _, { isEmpty } from 'lodash';
import path from 'path';
import * as R from 'ramda';
@@ -329,7 +329,7 @@ const booleanValuesRepresentingTrue: string[] = ['true', '1'];
const booleanValuesRepresentingFalse: string[] = ['false', '0'];
const normalizeValue = (value: any): string =>
value.toString().trim().toLowerCase();
value?.toString().trim().toLowerCase();
const booleanValues: string[] = [
...booleanValuesRepresentingTrue,
@@ -338,7 +338,7 @@ const booleanValues: string[] = [
export const parseBoolean = <T>(value: any, defaultValue: T): T | boolean => {
const normalizedValue = normalizeValue(value);
if (booleanValues.indexOf(normalizedValue) === -1) {
if (isEmpty(value) || booleanValues.indexOf(normalizedValue) === -1) {
return defaultValue;
}
return booleanValuesRepresentingTrue.indexOf(normalizedValue) !== -1;