refactor: Authentication service.

This commit is contained in:
Ahmed Bouhuolia
2020-08-31 22:15:44 +02:00
parent ca251a2d28
commit abefba22ee
35 changed files with 880 additions and 395 deletions

View File

@@ -0,0 +1,11 @@
export default class ServiceError {
errorType: string;
message: string;
constructor(errorType: string, message?: string) {
this.errorType = errorType;
this.message = message || null;
}
}

View File

@@ -0,0 +1,15 @@
import ServiceError from './ServiceError';
export default class ServiceErrors {
errors: ServiceError[];
constructor(errors: ServiceError[]) {
this.errors = errors;
}
hasType(errorType: string) {
return this.errors
.filter((error: ServiceError) => error.errorType === errorType);
}
}

View File

@@ -1,5 +1,9 @@
import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan';
import ServiceError from './ServiceError';
import ServiceErrors from './ServiceErrors';
export {
NotAllowedChangeSubscriptionPlan,
ServiceError,
ServiceErrors,
};

View File

@@ -1,327 +0,0 @@
import express from 'express';
import { check, validationResult } from 'express-validator';
import path from 'path';
import fs from 'fs';
import Mustache from 'mustache';
import jwt from 'jsonwebtoken';
import { pick } from 'lodash';
import uniqid from 'uniqid';
import moment from 'moment';
import Logger from '@/services/Logger';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import SystemUser from '@/system/models/SystemUser';
import mail from '@/services/mail';
import { hashPassword } from '@/utils';
import dbManager from '@/database/manager';
import Tenant from '@/system/models/Tenant';
import TenantUser from '@/models/TenantUser';
import TenantsManager from '@/system/TenantsManager';
import TenantModel from '@/models/TenantModel';
import PasswordReset from '@/system/models/PasswordReset';
export default {
/**
* Constructor method.
*/
router() {
const router = express.Router();
router.post('/login',
this.login.validation,
asyncMiddleware(this.login.handler));
router.post('/register',
this.register.validation,
asyncMiddleware(this.register.handler));
router.post('/send_reset_password',
this.sendResetPassword.validation,
asyncMiddleware(this.sendResetPassword.handler));
router.post('/reset/:token',
this.resetPassword.validation,
asyncMiddleware(this.resetPassword.handler));
return router;
},
/**
* User login authentication request.
*/
login: {
validation: [
check('crediential').exists().isEmail(),
check('password').exists().isLength({ min: 5 }),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const form = { ...req.body };
const { JWT_SECRET_KEY } = process.env;
Logger.log('info', 'Someone trying to login.', { form });
const user = await SystemUser.query()
.withGraphFetched('tenant')
.where('email', form.crediential)
.orWhere('phone_number', form.crediential)
.first();
if (!user) {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
});
}
if (!user.verifyPassword(form.password)) {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
});
}
if (!user.active) {
return res.boom.badRequest(null, {
errors: [{ type: 'USER_INACTIVE', code: 110 }],
});
}
const lastLoginAt = moment().format('YYYY/MM/DD HH:mm:ss');
const tenantDb = TenantsManager.knexInstance(user.tenant.organizationId);
TenantModel.knexBinded = tenantDb;
const updateTenantUser = TenantUser.tenant().query()
.where('id', user.id)
.update({ last_login_at: lastLoginAt });
const updateSystemUser = SystemUser.query()
.where('id', user.id)
.update({ last_login_at: lastLoginAt });
await Promise.all([updateTenantUser, updateSystemUser]);
const token = jwt.sign(
{ email: user.email, _id: user.id },
JWT_SECRET_KEY,
{ expiresIn: '1d' },
);
Logger.log('info', 'Logging success.', { form });
return res.status(200).send({ token, user });
},
},
/**
* Registers a new organization.
*/
register: {
validation: [
check('organization_name').exists().trim().escape(),
check('first_name').exists().trim().escape(),
check('last_name').exists().trim().escape(),
check('email').exists().trim().escape(),
check('phone_number').exists().trim().escape(),
check('password').exists().trim().escape(),
check('country').exists().trim().escape(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const form = { ...req.body };
Logger.log('info', 'Someone trying to register.', { form });
const user = await SystemUser.query()
.where('email', form.email)
.orWhere('phone_number', form.phone_number)
.first();
const errorReasons = [];
if (user && user.phoneNumber === form.phone_number) {
errorReasons.push({ type: 'PHONE_NUMBER_EXISTS', code: 100 });
}
if (user && user.email === form.email) {
errorReasons.push({ type: 'EMAIL_EXISTS', code: 200 });
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
const organizationId = uniqid();
const tenantOrganization = await Tenant.query().insert({
organization_id: organizationId,
});
const hashedPassword = await hashPassword(form.password);
const userInsert = {
...pick(form, ['first_name', 'last_name', 'email', 'phone_number']),
active: true,
};
const registeredUser = await SystemUser.query().insert({
...userInsert,
password: hashedPassword,
tenant_id: tenantOrganization.id,
});
await dbManager.createDb(`bigcapital_tenant_${organizationId}`);
const tenantDb = TenantsManager.knexInstance(organizationId);
await tenantDb.migrate.latest();
TenantModel.knexBinded = tenantDb;
await TenantUser.bindKnex(tenantDb).query().insert({
...userInsert,
invite_accepted_at: moment().format('YYYY/MM/DD HH:mm:ss'),
});
Logger.log('info', 'New tenant has been created.', { organizationId });
const filePath = path.join(global.rootPath, 'views/mail/Welcome.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, { ...form });
const mailOptions = {
to: userInsert.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Welcome to Bigcapital',
html: rendered,
};
mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.log('error', 'Failed send welcome mail', { error, form });
return;
}
Logger.log('info', 'User has been sent welcome email successfuly.', { form });
});
return res.status(200).send({
organization_id: organizationId,
});
},
},
/**
* Send reset password link via email or SMS.
*/
sendResetPassword: {
validation: [
check('email').exists().isEmail(),
],
// eslint-disable-next-line consistent-return
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const form = { ...req.body };
Logger.log('info', 'User trying to send reset password.', { form });
const user = await SystemUser.query().where('email', form.email).first();
if (!user) {
return res.status(400).send({
errors: [{ type: 'EMAIL.NOT.REGISTERED', code: 200 }],
});
}
// Delete all stored tokens of reset password that associate to the give email.
await PasswordReset.query()
.where('email', form.email)
.delete();
const token = uniqid();
const passwordReset = await PasswordReset.query()
.insert({ email: form.email, token });
const filePath = path.join(global.rootPath, 'views/mail/ResetPassword.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, {
url: `${req.protocol}://${req.hostname}/reset/${passwordReset.token}`,
first_name: user.firstName,
last_name: user.lastName,
// contact_us_email: config.contactUsMail,
});
const mailOptions = {
to: user.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Bigcapital - Password Reset',
html: rendered,
};
mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.log('error', 'Failed send reset password mail', { error, form });
return;
}
Logger.log('info', 'User has been sent reset password email successfuly.', { form });
});
res.status(200).send({ email: passwordReset.email });
},
},
/**
* Reset password.
*/
resetPassword: {
validation: [
check('password').exists().isLength({ min: 5 }).custom((value, { req }) => {
if (value !== req.body.confirm_password) {
throw new Error("Passwords don't match");
} else {
return value;
}
}),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
Logger.log('info', 'User trying to reset password.');
const { token } = req.params;
const { password } = req.body;
const tokenModel = await PasswordReset.query()
.where('token', token)
// .where('created_at', '>=', Date.now() - 3600000)
.first();
if (!tokenModel) {
return res.boom.badRequest(null, {
errors: [{ type: 'TOKEN_INVALID', code: 100 }],
});
}
const user = await SystemUser.query()
.where('email', tokenModel.email).first();
if (!user) {
return res.boom.badRequest(null, {
errors: [{ type: 'USER_NOT_FOUND', code: 120 }],
});
}
const hashedPassword = await hashPassword(password);
await SystemUser.query()
.where('email', tokenModel.email)
.update({
password: hashedPassword,
});
// Delete the reset password token.
await PasswordReset.query().where('token', token).delete();
Logger.log('info', 'User password has been reset successfully.');
return res.status(200).send({});
},
},
};

View File

@@ -0,0 +1,225 @@
import { Request, Response, Router } from 'express';
import { check, validationResult, matchedData, ValidationChain } from 'express-validator';
import { Service, Inject } from 'typedi';
import { camelCase, mapKeys } from 'lodash';
import validateMiddleware from '@/http/middleware/validateMiddleware';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import prettierMiddleware from '@/http/middleware/prettierMiddleware';
import AuthenticationService from '@/services/Authentication';
import { IUserOTD, ISystemUser, IRegisterOTD } from '@/interfaces';
import { ServiceError, ServiceErrors } from "@/exceptions";
import { IRegisterDTO } from 'src/interfaces';
@Service()
export default class AuthenticationController {
@Inject()
authService: AuthenticationService;
/**
* Constructor method.
*/
router() {
const router = Router();
router.post(
'/login',
this.loginSchema,
validateMiddleware,
asyncMiddleware(this.login.bind(this))
);
router.post(
'/register',
this.registerSchema,
validateMiddleware,
asyncMiddleware(this.register.bind(this))
);
router.post(
'/send_reset_password',
this.sendResetPasswordSchema,
validateMiddleware,
asyncMiddleware(this.sendResetPassword.bind(this))
);
router.post(
'/reset/:token',
this.resetPasswordSchema,
validateMiddleware,
asyncMiddleware(this.resetPassword.bind(this))
);
return router;
}
/**
* Login schema.
*/
get loginSchema(): ValidationChain[] {
return [
check('crediential').exists().isEmail(),
check('password').exists().isLength({ min: 5 }),
];
}
/**
* Register schema.
*/
get registerSchema(): ValidationChain[] {
return [
check('organization_name').exists().trim().escape(),
check('first_name').exists().trim().escape(),
check('last_name').exists().trim().escape(),
check('email').exists().trim().escape(),
check('phone_number').exists().trim().escape(),
check('password').exists().trim().escape(),
check('country').exists().trim().escape(),
];
}
/**
* Reset password schema.
*/
get resetPasswordSchema(): ValidationChain[] {
return [
check('password').exists().isLength({ min: 5 }).custom((value, { req }) => {
if (value !== req.body.confirm_password) {
throw new Error("Passwords don't match");
} else {
return value;
}
}),
]
}
get sendResetPasswordSchema(): ValidationChain[] {
return [
check('email').exists().isEmail().trim().escape(),
];
}
/**
* Handle user login.
* @param {Request} req
* @param {Response} res
*/
async login(req: Request, res: Response, next: Function): Response {
const userDTO: IUserOTD = mapKeys(matchedData(req, {
locations: ['body'],
includeOptionals: true,
}), (v, k) => camelCase(k));
try {
const { token, user } = await this.authService.signIn(
userDTO.crediential,
userDTO.password
);
return res.status(200).send({ token, user });
} catch (error) {
if (error instanceof ServiceError) {
if (error.errorType === 'invalid_details') {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
});
}
if (error.errorType === 'user_inactive') {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 200 }],
});
}
}
next();
}
}
/**
* Organization register handler.
* @param {Request} req
* @param {Response} res
*/
async register(req: Request, res: Response, next: Function) {
const registerDTO: IRegisterDTO = mapKeys(matchedData(req, {
locations: ['body'],
includeOptionals: true,
}), (v, k) => camelCase(k));
try {
const registeredUser = await this.authService.register(registerDTO);
return res.status(200).send({
code: 'REGISTER.SUCCESS',
message: 'Register organization has been success.',
});
} catch (error) {
if (error instanceof ServiceErrors) {
const errorReasons = [];
if (error.hasType('phone_number_exists')) {
errorReasons.push({ type: 'PHONE_NUMBER_EXISTS', code: 100 });
}
if (error.hasType('email_exists')) {
errorReasons.push({ type: 'EMAIL.EXISTS', code: 200 });
}
if (errorReasons.length > 0) {
return res.status(200).send({ errors: errorReasons });
}
}
next();
}
}
/**
* Send reset password handler
* @param {Request} req
* @param {Response} res
*/
async sendResetPassword(req: Request, res: Response, next: Function) {
const { email } = req.body;
try {
await this.authService.sendResetPassword(email);
return res.status(200).send({
code: 'SEND_RESET_PASSWORD_SUCCESS',
});
} catch(error) {
if (error instanceof ServiceError) {
if (error.errorType === 'email_not_found') {
return res.status(400).send({
errors: [{ type: 'EMAIL.NOT.REGISTERED', code: 200 }],
});
}
}
next();
}
}
/**
* Reset password handler
* @param {Request} req
* @param {Response} res
*/
async resetPassword(req: Request, res: Response) {
const { token } = req.params;
const { password } = req.body;
try {
await this.authService.resetPassword(token, password);
return res.status(200).send({
type: 'RESET_PASSWORD_SUCCESS',
})
} catch(error) {
console.log(error);
if (error instanceof ServiceError) {
if (error.errorType === 'token_invalid') {
return res.boom.badRequest(null, {
errors: [{ type: 'TOKEN_INVALID', code: 100 }],
});
}
if (error.errorType === 'user_not_found') {
return res.boom.badRequest(null, {
errors: [{ type: 'USER_NOT_FOUND', code: 120 }],
});
}
}
}
}
};

View File

@@ -10,7 +10,6 @@ import path from 'path';
import fs from 'fs';
import Mustache from 'mustache';
import moment from 'moment';
import mail from '@/services/mail';
import { hashPassword } from '@/utils';
import SystemUser from '@/system/models/SystemUser';
import Invite from '@/system/models/Invite';

View File

@@ -31,7 +31,7 @@ import TenantDependencyInjection from '@/http/middleware/TenantDependencyInjecti
import SubscriptionMiddleware from '@/http/middleware/SubscriptionMiddleware';
export default (app) => {
app.use('/api/auth', Authentication.router());
app.use('/api/auth', Container.get(Authentication).router());
app.use('/api/invite', InviteUsers.router());
app.use('/api/vouchers', Container.get(VouchersController).router());
app.use('/api/subscription', Container.get(Subscription).router());

View File

@@ -0,0 +1,10 @@
export interface IRegisterDTO {
firstName: string,
lastName: string,
email: string,
password: string,
organizationName: string,
};

View File

@@ -0,0 +1,9 @@
export interface ISystemUser {
}
export interface ISystemUserDTO {
}

View File

@@ -28,6 +28,13 @@ import {
ISaleEstimate,
ISaleEstimateOTD,
} from './SaleEstimate';
import {
IRegisterDTO,
} from './Register';
import {
ISystemUser,
ISystemUserDTO,
} from './User';
export {
IBillPaymentEntry,
@@ -58,4 +65,8 @@ export {
IPaymentReceive,
IPaymentReceiveOTD,
IRegisterDTO,
ISystemUser,
ISystemUserDTO,
};

View File

@@ -1,8 +0,0 @@
export default class MailNotificationSubscribeEnd {
handler(job) {
}
}

View File

@@ -1,6 +1,27 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class MailNotificationSubscribeEnd {
/**
*
* @param {Job} job -
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
}
Logger.debug(`Send mail notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.mailMessages.sendRemainingTrialPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send mail notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,27 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class MailNotificationTrialEnd {
/**
*
* @param {Job} job -
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.debug(`Send mail notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.mailMessages.sendRemainingTrialPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send mail notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
import Mustache from 'mustache';
import { Container } from 'typedi';
export default class ResetPasswordMailJob {
/**
*
* @param job
* @param done
*/
handler(job, done) {
const { user, token } = job.attrs.data;
const Logger = Container.get('logger');
const Mail = Container.get('mail');
const filePath = path.join(global.rootPath, 'views/mail/ResetPassword.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, {
url: `https://google.com/reset/${token}`,
first_name: user.firstName,
last_name: user.lastName,
// contact_us_email: config.contactUsMail,
});
const mailOptions = {
to: user.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Bigcapital - Password Reset',
html: rendered,
};
Mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.info('[send_reset_password] failed send reset password mail', { error, user });
done(error);
return;
}
Logger.info('[send_reset_password] user has been sent reset password email successfuly.', { user });
done();
});
res.status(200).send({ email: passwordReset.email });
}
}

View File

@@ -1,13 +1,28 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class SMSNotificationSubscribeEnd {
/**
*
* @param {Job}job
*/
handler(job) {
const { tenantId, subscriptionSlug } = job.attrs.data;
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.debug(`Send SMS notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.smsMessages.sendRemainingSubscriptionPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send SMS notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send SMS notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -1,8 +1,28 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class SMSNotificationTrialEnd {
export default class MailNotificationSubscribeEnd {
/**
*
* @param {Job}job
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.debug(`Send notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.smsMessages.sendRemainingTrialPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,8 @@
export default class UserInviteMailJob {
handler(job, done) {
}
}

View File

@@ -1,11 +1,38 @@
import fs from 'fs';
import Mustache from 'mustache';
import path from 'path';
import { Container } from 'typedi';
import MailerService from '../services/mailer';
export default class WelcomeEmailJob {
/**
*
* @param {Job} job
* @param {Function} done
*/
public async handler(job, done: Function): Promise<void> {
const { email, organizationName, firstName } = job.attrs.data;
const Logger = Container.get('logger');
const Mail = Container.get('mail');
console.log('✌Email Sequence Job triggered!');
done();
const filePath = path.join(global.rootPath, 'views/mail/Welcome.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, {
email, organizationName, firstName,
});
const mailOptions = {
to: email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Welcome to Bigcapital',
html: rendered,
};
Mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.error('Failed send welcome mail', { error, form });
done(error);
return;
}
Logger.info('User has been sent welcome email successfuly.', { form });
done();
});
}
}

View File

@@ -2,6 +2,7 @@ import { Container } from 'typedi';
import LoggerInstance from '@/services/Logger';
import agendaFactory from '@/loaders/agenda';
import SmsClientLoader from '@/loaders/smsClient';
import mailInstance from '@/loaders/mail';
export default ({ mongoConnection, knex }) => {
try {
@@ -20,6 +21,9 @@ export default ({ mongoConnection, knex }) => {
Container.set('SMSClient', smsClientInstance);
LoggerInstance.info('SMS client has been injected into container');
Container.set('mail', mailInstance);
LoggerInstance.info('Mail instance has been injected into container');
return { agenda: agendaInstance };
} catch (e) {
LoggerInstance.error('Error on dependency injector loader: %o', e);

View File

@@ -0,0 +1,3 @@
// Here we import all events.
import '@/subscribers/authentication';

View File

@@ -1,9 +1,15 @@
import Agenda from 'agenda';
import WelcomeEmailJob from '@/Jobs/welcomeEmail';
import ResetPasswordMailJob from '@/Jobs/ResetPasswordMail';
import ComputeItemCost from '@/Jobs/ComputeItemCost';
import RewriteInvoicesJournalEntries from '@/jobs/writeInvoicesJEntries';
import SendVoucherViaPhoneJob from '@/jobs/SendVoucherPhone';
import SendVoucherViaEmailJob from '@/jobs/SendVoucherEmail';
import SendSMSNotificationSubscribeEnd from '@/jobs/SMSNotificationSubscribeEnd';
import SendSMSNotificationTrialEnd from '@/jobs/SMSNotificationTrialEnd';
import SendMailNotificationSubscribeEnd from '@/jobs/MailNotificationSubscribeEnd';
import SendMailNotificationTrialEnd from '@/jobs/MailNotificationTrialEnd';
import UserInviteMailJob from '@/jobs/UserInviteMail';
export default ({ agenda }: { agenda: Agenda }) => {
agenda.define(
@@ -11,6 +17,16 @@ export default ({ agenda }: { agenda: Agenda }) => {
{ priority: 'high' },
new WelcomeEmailJob().handler,
);
agenda.define(
'reset-password-mail',
{ priority: 'high' },
new ResetPasswordMailJob().handler,
);
agenda.define(
'user-invite-mail',
{ priority: 'high' },
new UserInviteMailJob().handler,
)
agenda.define(
'compute-item-cost',
{ priority: 'high', concurrency: 20 },
@@ -31,21 +47,25 @@ export default ({ agenda }: { agenda: Agenda }) => {
{ priority: 'high', concurrency: 1, },
new SendVoucherViaEmailJob().handler,
);
// agenda.define(
// 'send-sms-notification-subscribe-end',
// { priority: 'high', concurrency: 1, },
// );
// agenda.define(
// 'send-mail-notification-subscribe-end',
// { priority: 'high', concurrency: 1, },
// );
// agenda.define(
// 'send-sms-notification-trial-end',
// { priority: 'high', concurrency: 1, },
// );
// agenda.define(
// 'send-mail-notification-trial-end',
// { priority: 'high', concurrency: 1, },
// );
agenda.define(
'send-sms-notification-subscribe-end',
{ priority: 'nromal', concurrency: 1, },
new SendSMSNotificationSubscribeEnd().handler,
);
agenda.define(
'send-sms-notification-trial-end',
{ priority: 'normal', concurrency: 1, },
new SendSMSNotificationTrialEnd().handler,
);
agenda.define(
'send-mail-notification-subscribe-end',
{ priority: 'high', concurrency: 1, },
new SendMailNotificationSubscribeEnd().handler
);
agenda.define(
'send-mail-notification-trial-end',
{ priority: 'high', concurrency: 1, },
new SendMailNotificationTrialEnd().handler
);
agenda.start();
};

View File

@@ -12,14 +12,4 @@ const transporter = nodemailer.createTransport({
},
});
console.log({
host: config.mail.host,
port: config.mail.port,
secure: config.mail.secure, // true for 465, false for other ports
auth: {
user: config.mail.username,
pass: config.mail.password,
},
});
export default transporter;
export default transporter;

View File

@@ -0,0 +1,252 @@
import { Service, Inject, Container } from "typedi";
import JWT from 'jsonwebtoken';
import uniqid from 'uniqid';
import { omit } from 'lodash';
import {
EventDispatcher
EventDispatcherInterface
} from '@/decorators/eventDispatcher';
import {
SystemUser,
PasswordReset,
Tenant,
} from '@/system/models';
import {
IRegisterDTO,
ITenant,
ISystemUser,
IPasswordReset,
} from '@/interfaces';
import TenantsManager from "@/system/TenantsManager";
import { hashPassword } from '@/utils';
import { ServiceError, ServiceErrors } from "@/exceptions";
import config from '@/../config/config';
import events from '@/subscribers/events';
@Service()
export default class AuthenticationService {
@Inject('logger')
logger: any;
@Inject()
tenantsManager: TenantsManager;
@EventDispatcher()
eventDispatcher: EventDispatcherInterface;
/**
* Signin and generates JWT token.
* @throws {ServiceError}
* @param {string} emailOrPhone - Email or phone number.
* @param {string} password - Password.
* @return {Promise<{user: IUser, token: string}>}
*/
async signIn(emailOrPhone: string, password: string): Promise<{user: IUser, token: string }> {
this.logger.info('[login] Someone trying to login.', { emailOrPhone, password });
const user = await SystemUser.query()
.where('email', emailOrPhone)
.orWhere('phone_number', emailOrPhone)
.withGraphFetched('tenant')
.first();
if (!user) {
this.logger.info('[login] invalid data');
throw new ServiceError('invalid_details');
}
this.logger.info('[login] check password validation.');
if (!user.verifyPassword(password)) {
throw new ServiceError('password_invalid');
}
if (!user.active) {
this.logger.info('[login] user inactive.');
throw new ServiceError('user_inactive');
}
this.logger.info('[login] generating JWT token.');
const token = this.generateToken(user);
this.logger.info('[login] Logging success.', { user, token });
this.eventDispatcher.dispatch(events.auth.login, {
emailOrPhone, password,
});
return { user, token };
}
/**
* Validates email and phone number uniqiness on the storage.
* @throws {ServiceErrors}
* @param {IRegisterDTO} registerDTO - Register data object.
*/
private async validateEmailAndPhoneUniqiness(registerDTO: IRegisterDTO) {
const user: ISystemUser = await SystemUser.query()
.where('email', registerDTO.email)
.orWhere('phone_number', registerDTO.phoneNumber)
.first();
const errorReasons: ServiceErrors[] = [];
if (user && user.phoneNumber === registerDTO.phoneNumber) {
this.logger.info('[register] phone number exists on the storage.');
errorReasons.push(new ServiceError('phone_number_exists'));
}
if (user && user.email === registerDTO.email) {
this.logger.info('[register] email exists on the storage.');
errorReasons.push(new ServiceError('email_exists'));
}
if (errorReasons.length > 0) {
throw new ServiceErrors(errorReasons);
}
}
/**
* Registers a new tenant with user from user input.
* @throws {ServiceErrors}
* @param {IUserDTO} user
*/
async register(registerDTO: IRegisterDTO): Promise<ISystemUser> {
this.logger.info('[register] Someone trying to register.');
await this.validateEmailAndPhoneUniqiness(registerDTO);
this.logger.info('[register] Creating a new tenant org.')
const tenant = await this.newTenantOrganization();
this.logger.info('[register] Trying hashing the password.')
const hashedPassword = await hashPassword(registerDTO.password);
const registeredUser = await SystemUser.query().insert({
...omit(registerDTO, 'country', 'organizationName'),
active: true,
password: hashedPassword,
tenant_id: tenant.id,
});
this.eventDispatcher.dispatch(events.auth.register, { registerDTO });
return registeredUser;
}
/**
* Generates and insert new tenant organization id.
* @async
* @return {Promise<ITenant>}
*/
private async newTenantOrganization(): Promise<ITenant> {
const organizationId = uniqid();
const tenantOrganization = await Tenant.query().insert({
organization_id: organizationId,
});
return tenantOrganization;
}
/**
* Initialize tenant database.
* @param {number} tenantId - The given tenant id.
* @return {void}
*/
async initializeTenant(tenantId: number): Promise<void> {
const dbManager = Container.get('dbManager');
const tenant = await Tenant.query().findById(tenantId);
this.logger.info('[tenant_init] Tenant DB creating.', { tenant });
await dbManager.createDb(`bigcapital_tenant_${tenant.organizationId}`);
const tenantDb = this.tenantsManager.knexInstance(tenant.organizationId);
this.logger.info('[tenant_init] Tenant DB migrating to latest version.', { tenant });
await tenantDb.migrate.latest();
}
/**
* Validate the given email existance on the storage.
* @throws {ServiceError}
* @param {string} email - email address.
*/
private async validateEmailExistance(email: string) {
const foundEmail = await SystemUser.query().findOne('email', email);
if (!foundEmail) {
this.logger.info('[send_reset_password] The given email not found.');
throw new ServiceError('email_not_found');
}
}
/**
* Generates and retrieve password reset token for the given user email.
* @param {string} email
* @return {<Promise<IPasswordReset>}
*/
async sendResetPassword(email: string): Promise<IPasswordReset> {
this.logger.info('[send_reset_password] Trying to send reset password.');
await this.validateEmailExistance(email);
// Delete all stored tokens of reset password that associate to the give email.
await PasswordReset.query().where('email', email).delete();
const token = uniqid();
const passwordReset = await PasswordReset.query().insert({ email, token });
const user = await SystemUser.query().findOne('email', email);
this.eventDispatcher.dispatch(events.auth.sendResetPassword, { user, token });
return passwordReset;
}
/**
* Resets a user password from given token.
* @param {string} token - Password reset token.
* @param {string} password - New Password.
* @return {Promise<void>}
*/
async resetPassword(token: string, password: string): Promise<void> {
const tokenModel = await PasswordReset.query().findOne('token', token)
if (!tokenModel) {
this.logger.info('[reset_password] token invalid.');
throw new ServiceError('token_invalid');
}
const user = await SystemUser.query().findOne('email', tokenModel.email)
if (!user) {
throw new ServiceError('user_not_found');
}
const hashedPassword = await hashPassword(password);
this.logger.info('[reset_password] saving a new hashed password.');
await SystemUser.query()
.where('email', tokenModel.email)
.update({
password: hashedPassword,
});
// Delete the reset password token.
await PasswordReset.query().where('email', user.email).delete();
this.eventDispatcher.dispatch(events.auth.sendResetPassword, { user, token, password });
this.logger.info('[reset_password] reset password success.');
}
/**
* Generates JWT token for the given user.
* @param {IUser} user
* @return {string} token
*/
generateToken(user: IUser): string {
const today = new Date();
const exp = new Date(today);
exp.setDate(today.getDate() + 60);
this.logger.silly(`Sign JWT for userId: ${user._id}`);
return JWT.sign(
{
_id: user._id, // We are gonna use this in the middleware 'isAuth'
exp: exp.getTime() / 1000,
},
config.jwtSecret,
);
}
}

View File

@@ -2,7 +2,6 @@ import fs from 'fs';
import path from 'path';
import Mustache from 'mustache';
import { Container } from 'typedi';
import mail from '@/services/mail';
export default class SubscriptionMailMessages {
/**
@@ -11,7 +10,8 @@ export default class SubscriptionMailMessages {
* @param {email} email
*/
public async sendMailVoucher(voucherCode: string, email: string) {
const logger = Container.get('logger');
const Logger = Container.get('logger');
const Mail = Container.get('mail');
const filePath = path.join(global.rootPath, 'views/mail/VoucherReceive.html');
const template = fs.readFileSync(filePath, 'utf8');
@@ -24,7 +24,7 @@ export default class SubscriptionMailMessages {
html: rendered,
};
return new Promise((resolve, reject) => {
mail.sendMail(mailOptions, (error) => {
Mail.sendMail(mailOptions, (error) => {
if (error) {
reject(error);
return;

View File

@@ -326,7 +326,7 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
/**
* Schedules compute sale invoice items cost based on each item
* cost method.
* @param {ISaleInvoice} saleInvoice
* @param {ISaleInvoice} saleInvoice
* @return {Promise}
*/
async scheduleComputeInvoiceItemsCost(
@@ -343,7 +343,7 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
.filter((entry: IItemEntry) => entry.item.type === 'inventory')
.map((entry: IItemEntry) => entry.itemId)
.uniq().value();
if (inventoryItemsIds.length === 0) {
await this.writeNonInventoryInvoiceJournals(tenantId, saleInvoice, override);
} else {

View File

@@ -0,0 +1,21 @@
import { Service } from "typedi";
@Service()
export default class SubscriptionMailMessages {
public async sendRemainingSubscriptionPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining subscription is ${remainingDays} days,
please renew your subscription before expire.
`;
this.smsClient.sendMessage(phoneNumber, message);
}
public async sendRemainingTrialPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining free trial is ${remainingDays} days,
please subscription before ends, if you have any quation to contact us.`;
this.smsClient.sendMessage(phoneNumber, message);
}
}

View File

@@ -0,0 +1,24 @@
import { Service, Inject } from 'typedi';
import SMSClient from '@/services/SMSClient';
@Service()
export default class SubscriptionSMSMessages {
@Inject('SMSClient')
smsClient: SMSClient;
public async sendRemainingSubscriptionPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining subscription is ${remainingDays} days,
please renew your subscription before expire.
`;
this.smsClient.sendMessage(phoneNumber, message);
}
public async sendRemainingTrialPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining free trial is ${remainingDays} days,
please subscription before ends, if you have any quation to contact us.`;
this.smsClient.sendMessage(phoneNumber, message);
}
}

View File

@@ -1,11 +1,19 @@
import { Service } from 'typedi';
import { Service, Inject } from 'typedi';
import { Plan, Tenant, Voucher } from '@/system/models';
import Subscription from '@/services/Subscription/Subscription';
import VocuherPaymentMethod from '@/services/Payment/VoucherPaymentMethod';
import PaymentContext from '@/services/Payment';
import SubscriptionSMSMessages from '@/services/Subscription/SMSMessages';
import SubscriptionMailMessages from '@/services/Subscription/MailMessages';
@Service()
export default class SubscriptionService {
@Inject()
smsMessages: SubscriptionSMSMessages;
@Inject()
mailMessages: SubscriptionMailMessages;
/**
* Handles the payment process via voucher code and than subscribe to
* the given tenant.

View File

@@ -0,0 +1,41 @@
import { Container } from 'typedi';
import { pick } from 'lodash';
import { EventSubscriber, On } from 'event-dispatch';
import events from '@/subscribers/events';
@EventSubscriber()
export class AuthenticationSubscriber {
@On(events.auth.login)
public onLogin(payload) {
const { emailOrPhone, password } = payload;
}
@On(events.auth.register)
public onRegister(payload) {
const { registerDTO } = payload;
const agenda = Container.get('agenda');
// Send welcome mail to the user.
agenda.now('welcome-email', {
...pick(registerDTO, ['email', 'organizationName', 'firstName']),
});
}
@On(events.auth.resetPassword)
public onResetPassword(payload) {
}
@On(events.auth.sendResetPassword)
public onSendResetPassword (payload) {
const { user, token } = payload;
const agenda = Container.get('agenda');
// Send reset password mail.
agenda.now('reset-password-mail', { user, token })
}
}

View File

@@ -4,6 +4,7 @@ export default {
auth: {
login: 'onLogin',
register: 'onRegister',
sendResetPassword: 'onSendResetPassword',
resetPassword: 'onResetPassword',
},
}

View File

View File

@@ -1,5 +1,6 @@
import Knex from 'knex';
import { knexSnakeCaseMappers } from 'objection';
import { Service } from 'typedi';
import Tenant from '@/system/models/Tenant';
import config from '@/../config/config';
import TenantModel from '@/models/TenantModel';
@@ -17,13 +18,14 @@ import TenantUser from '@/models/TenantUser';
// tenantOrganizationId: String,
// }
@Service()
export default class TenantsManager {
constructor() {
this.knexCache = new Map();
}
static async getTenant(organizationId) {
async getTenant(organizationId) {
const tenant = await Tenant.query()
.where('organization_id', organizationId).first();
@@ -35,7 +37,7 @@ export default class TenantsManager {
* @param {Integer} uniqId
* @return {TenantWebsite}
*/
static async createTenant(uniqId) {
async createTenant(uniqId) {
const organizationId = uniqId || uniqid();
const tenantOrganization = await Tenant.query().insert({
organization_id: organizationId,
@@ -58,7 +60,7 @@ export default class TenantsManager {
* Drop tenant database of the given tenant website.
* @param {TenantWebsite} tenantWebsite
*/
static async dropTenant(tenantWebsite) {
async dropTenant(tenantWebsite) {
const tenantDbName = `bigcapital_tenant_${tenantWebsite.organizationId}`;
await dbManager.dropDb(tenantDbName);
@@ -69,7 +71,7 @@ export default class TenantsManager {
/**
* Creates a user that associate to the given tenant.
*/
static async createTenantUser(tenantWebsite, user) {
async createTenantUser(tenantWebsite, user) {
const userInsert = { ...user };
const systemUser = await SystemUser.query().insert({
@@ -92,7 +94,7 @@ export default class TenantsManager {
/**
* Retrieve all tenants metadata from system storage.
*/
static getAllTenants() {
getAllTenants() {
return Tenant.query();
}
@@ -100,7 +102,7 @@ export default class TenantsManager {
* Retrieve the given organization id knex configuration.
* @param {String} organizationId -
*/
static getTenantKnexConfig(organizationId) {
getTenantKnexConfig(organizationId) {
return {
client: config.tenant.db_client,
connection: {
@@ -120,7 +122,7 @@ export default class TenantsManager {
};
}
static knexInstance(organizationId) {
knexInstance(organizationId) {
const knexCache = new Map();
let knex = knexCache.get(organizationId);

View File

@@ -4,6 +4,8 @@ import PlanFeature from './Subscriptions/PlanFeature';
import PlanSubscription from './Subscriptions/PlanSubscription';
import Voucher from './Subscriptions/Voucher';
import Tenant from './Tenant';
import SystemUser from './SystemUser';
import PasswordReset from './PasswordReset';
export {
Plan,
@@ -11,4 +13,6 @@ export {
PlanSubscription,
Voucher,
Tenant,
SystemUser,
PasswordReset,
}