mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-16 12:50:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -153,6 +153,13 @@ module.exports = {
|
||||
),
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign-up email confirmation
|
||||
*/
|
||||
signupConfirmation: {
|
||||
enabled: parseBoolean<boolean>(process.env.SIGNUP_EMAIL_CONFIRMATION, false),
|
||||
},
|
||||
|
||||
/**
|
||||
* Puppeteer remote browserless connection.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export default {
|
||||
signUp: 'onSignUp',
|
||||
signingUp: 'onSigningUp',
|
||||
|
||||
signUpConfirming: 'signUpConfirming',
|
||||
signUpConfirmed: 'signUpConfirmed',
|
||||
|
||||
sendingResetPassword: 'onSendingResetPassword',
|
||||
sendResetPassword: 'onSendResetPassword',
|
||||
|
||||
|
||||
@@ -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) => {};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user