Skip to content

Commit

Permalink
Merge pull request #426 from bigcapitalhq/big-163-user-email-verifica…
Browse files Browse the repository at this point in the history
…tion-after-signing-up

feat: User email verification after signing-up.
  • Loading branch information
abouolia authored May 6, 2024
2 parents f6a0476 + dd02ae4 commit d7cad17
Show file tree
Hide file tree
Showing 38 changed files with 1,193 additions and 54 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ SIGNUP_DISABLED=false
SIGNUP_ALLOWED_DOMAINS=
SIGNUP_ALLOWED_EMAILS=

# Sign-up Email Confirmation
SIGNUP_EMAIL_CONFIRMATION=false

# API rate limit (points,duration,block duration).
API_RATE_LIMIT=120,60,600

Expand Down
79 changes: 79 additions & 0 deletions packages/server/src/api/controllers/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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[]}
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ module.exports = {
),
},

/**
* Sign-up email confirmation
*/
signupConfirmation: {
enabled: parseBoolean<boolean>(process.env.SIGNUP_EMAIL_CONFIRMATION, false),
},

/**
* Puppeteer remote browserless connection.
*/
Expand Down
21 changes: 16 additions & 5 deletions packages/server/src/interfaces/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 3 additions & 1 deletion packages/server/src/loaders/eventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -222,6 +223,7 @@ export const susbcribers = () => {
DeleteCashflowTransactionOnUncategorize,
PreventDeleteTransactionOnDelete,

SubscribeFreeOnSignupCommunity
SubscribeFreeOnSignupCommunity,
SendVerfiyMailOnSignUp
];
};
2 changes: 2 additions & 0 deletions packages/server/src/loaders/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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', {});
Expand Down
33 changes: 32 additions & 1 deletion packages/server/src/services/Authentication/AuthApplication.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Service, Inject, Container } from 'typedi';
import { Service, Inject } from 'typedi';
import {
IRegisterDTO,
ISystemUser,
Expand All @@ -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 {
Expand All @@ -18,6 +21,12 @@ export default class AuthenticationApplication {
@Inject()
private authSignupService: AuthSignupService;

@Inject()
private authSignupConfirmService: AuthSignupConfirmService;

@Inject()
private authSignUpConfirmResendService: AuthSignupConfirmResend;

@Inject()
private authResetPasswordService: AuthSendResetPassword;

Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions packages/server/src/services/Authentication/AuthSignup.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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, {
Expand Down
57 changes: 57 additions & 0 deletions packages/server/src/services/Authentication/AuthSignupConfirm.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
34 changes: 34 additions & 0 deletions packages/server/src/services/Authentication/AuthSignupResend.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit d7cad17

Please sign in to comment.