diff --git a/package.json b/package.json index 36db628..8e9a335 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,14 @@ "express-jwt": "^8.4.1", "form-data": "^4.0.0", "helmet": "^7.0.0", + "jsonschema": "^1.4.1", "jsonwebtoken": "^9.0.2", "knex": "^2.5.1", "mongodb-core": "^3.2.7", "mongoose": "^7.5.4", "morgan": "^1.10.0", "nodemailer": "^6.9.5", + "nodemailer-sendgrid": "^1.0.3", "nodemon": "^3.0.1", "objection": "^3.1.2", "uuid": "^9.0.1" diff --git a/src/apps/public/account/routes.js b/src/apps/public/account/routes.js new file mode 100644 index 0000000..2a7c784 --- /dev/null +++ b/src/apps/public/account/routes.js @@ -0,0 +1,14 @@ +'use strict'; +const router = require('express').Router(); +const services= require('./services.js'); + +router.post('/authorize', services.AuthorizeJWT); +router.get('/authorize/:session_token', services.RenewJWT); + +router.post('/signup', services.TryCreateAccount); +router.patch('/signup', services.ConfirmAccount); + +router.post('/recover', services.RecoverPwd); +router.patch('/recover', services.ConfirmRecoverPwd); + +module.exports = router; diff --git a/src/apps/public/account/services.js b/src/apps/public/account/services.js new file mode 100644 index 0000000..55b5b8f --- /dev/null +++ b/src/apps/public/account/services.js @@ -0,0 +1,263 @@ +"use strict"; +const jsonwebtoken = require('jsonwebtoken'); +const { API_CONFIG, ROOT_PATH, LIB_PATH, HANDLERS_PATH } = process.env; +const apiConfig = require( `${ROOT_PATH}/${API_CONFIG}` ); +const { genKey, toSha256 } = require( `${ROOT_PATH}/${LIB_PATH}/Misc.js` ); +const { emailEvent , EMAIL_EVENTS } = require( `${ROOT_PATH}/${HANDLERS_PATH}/MailClient` ); +const { create_account, already_exists, login, login_with_session_token, reset_password } = require( `${ROOT_PATH}/${HANDLERS_PATH}/Account` ); +const { Validator } = require( "jsonschema" ); + +const jwtSecret = apiConfig.authentication.jwtSecret; +const jwtTimeout = apiConfig.authentication.jwtTimeout;//Timeout in hours +const jwtRenewalTimeout = apiConfig.authentication.jwtRenewalTimeout;//Timeout in hours +const jwtOptions = apiConfig.authentication.jwtOptions; +const validator = new Validator(); + +const create_account_schema = { + type : 'object', + properties : { + email : { type : 'string' , maxLength : 256 }, + password : { type : 'string', maxLength : 256}, + otp : { type : 'string', maxLength : 6 }, + company_type : { type : 'string', enum : ["Shipper" , "Carrier"] }, + checksum : { type : 'string', maxLength : 32 } + }, + required : [ 'email', 'password' ] +}; + +const confirm_account_schema = { + type : 'object', + properties : create_account_schema.properties,//Same properties + required : [ 'email', 'password', 'otp', 'company_type', 'checksum' ]//Different requirements +}; + +const login_account_schema = { + type : 'object', + properties : create_account_schema.properties,//Same properties + required : [ 'email', 'password' ]//Different requirements +}; + +const password_recover_schema = create_account_schema; +const confirm_password_recover_schema = { + type : 'object', + properties : create_account_schema.properties,//Same properties + required : [ 'email', 'password', 'otp', 'checksum' ]//Different requirements +}; + +const AuthorizeJWT = async(req, res) => { + try{ + if( validator.validate( req.body , login_account_schema ).valid ){ + const { email, password } = req.body; + const user = await login( email, password ); + if( !user ){ + return res.status(401).send( { error : "Invalid credentials" } ); + } + const current_date = new Date(); + const iat = Math.floor( (current_date.getTime())/1000 ); + const renewal_exp = ( iat + 3600*jwtRenewalTimeout ) * 1000; + + /** + * Renew session token on every login event. + * Previous session token is lost + */ + const session_token = toSha256( `${new Date()}` ); + const session_token_exp = new Date( renewal_exp ); + user.session_token = session_token; + user.session_token_exp = session_token_exp; + await user.save(); + + const payload = { + iat: iat, + exp: iat + jwtTimeout * 3600, + aud: jwtOptions.audience, + iss: jwtOptions.audience, + sub: user.id, + }; + const jwt = jsonwebtoken.sign( payload , jwtSecret ); + return res.status(200).send( { + accessToken : jwt, + payload : payload, + session_token, + session_token_exp, + user : user + } ); + }else{ + return res.status(400).send( { error : "Invalid request" } ); + } + }catch( err ){ + console.error( err ); + res.status(500).send({ error : "Login: Internal error" }); + } +}; + +const RenewJWT = async(req, res) => { + try{ + const login_session_token = req.params.session_token; + const user = await login_with_session_token( login_session_token ); + if( !user ){ + return res.status(401).send( { error : "Invalid or Expired Session Token" } ); + } + const current_date = new Date(); + const iat = Math.floor( (current_date.getTime())/1000 ); + const renewal_exp = ( iat + 3600*jwtRenewalTimeout ) * 1000; + + /** + * Renew session token on every login event. + * Previous session token is lost + */ + const session_token = toSha256( `${new Date()}` ); + const session_token_exp = new Date( renewal_exp ); + user.session_token = session_token; + user.session_token_exp = session_token_exp; + await user.save(); + + const payload = { + iat: iat, + exp: iat + jwtTimeout * 3600, + aud: jwtOptions.audience, + iss: jwtOptions.audience, + sub: user.id, + }; + const jwt = jsonwebtoken.sign( payload , jwtSecret ); + return res.status(200).send( { + accessToken : jwt, + payload : payload, + session_token, + session_token_exp, + user : user + } ); + }catch( err ){ + console.error( err ); + res.status(500).send({ error : "Renew: Internal error" }); + } +}; + +const TryCreateAccount = async(req, res) => { + try{ + if( validator.validate( req.body , create_account_schema ).valid ){ + const otp = genKey(); + const { email : receiver , password } = req.body; + const email = receiver; + + const it_exists = await already_exists( email ); + if( it_exists ){ + return res.status(400).send({ error : "Email already exists" }); + } + + const content = { OTP : otp, user_name : email }; + const checksum_entry = { + email : receiver, + password, + otp + }; + const checksum = toSha256( JSON.stringify(checksum_entry)).substr(0, 32); + await emailEvent( EMAIL_EVENTS.ACCOUNT_VERIFY , receiver , content ); + console.log( + content + ); + res.status(200).send( { checksum } ); + }else{ + res.status(400).send( { error : "Invalid request" } ); + } + }catch( err ){ + console.error( err ); + res.status(500).send({ error : "Account creation: Internal error" }); + } +}; + +const ConfirmAccount = async(req, res) => { + try{ + if( validator.validate( req.body , confirm_account_schema ).valid ){ + const { email, password, otp, company_type, checksum } = req.body; + + const it_exists = await already_exists( email ); + if( it_exists ){ + return res.status(400).send({ error : "User already registered!" }); + } + + const checksum_entry = {email, password, otp}; + const recomputed_checksum = toSha256( JSON.stringify(checksum_entry)).substr(0, 32); + if( recomputed_checksum != checksum ){ + return res.status(400).send({ error : "Wrong OTP" }); + } + + await create_account( email, password, company_type ); + + const content = { user_name : email }; + const receiver = email; + // await emailEvent( EMAIL_EVENTS.ACCOUNT_CONFIRMED , receiver , content ); + console.log( + content + ); + return res.status(200).send( { msg : "User created successfully!" } ); + }else{ + return res.status(400).send( { error : "Invalid request" } ); + } + }catch( err ){ + console.error( err ); + return res.status(500).send({ error : "Account creation: Internal error" }); + } +}; + +const RecoverPwd = async(req, res) => { + try{ + if( validator.validate( req.body , password_recover_schema ).valid ){ + const otp = genKey(); + const { email : receiver , password } = req.body; + const email = receiver; + + const it_exists = await already_exists( email ); + if( !it_exists ){ + return res.status(400).send({ error : "Email is not registered!" }); + } + + const content = { OTP : otp, user_name : email }; + const checksum_entry = { + email : receiver, + password, + otp + }; + const checksum = toSha256( JSON.stringify(checksum_entry)).substr(0, 32); + await emailEvent( EMAIL_EVENTS.ACCOUNT_VERIFY , receiver , content ); + console.log( + content + ); + res.status(200).send( { checksum } ); + }else{ + res.status(400).send( { error : "Invalid request" } ); + } + }catch( err ){ + console.error( err ); + res.status(500).send({ error : "Password Recover: Internal error" }); + } +}; + +const ConfirmRecoverPwd = async(req, res) => { + try{ + if( validator.validate( req.body , confirm_password_recover_schema ).valid ){ + const { email, password, otp, checksum } = req.body; + + const it_exists = await already_exists( email ); + if( !it_exists ){ + return res.status(400).send({ error : "Email is not registered!" }); + } + + const checksum_entry = {email, password, otp}; + const recomputed_checksum = toSha256( JSON.stringify(checksum_entry)).substr(0, 32); + if( recomputed_checksum != checksum ){ + return res.status(400).send({ error : "Wrong OTP" }); + } + + await reset_password( email, password ); + + return res.status(200).send( { msg : "Password is reset!" } ); + }else{ + return res.status(400).send( { error : "Invalid request" } ); + } + }catch( err ){ + console.error( err ); + return res.status(500).send({ error : "Password Recover Confirmation: Internal error" }); + } +}; + +module.exports = { AuthorizeJWT, RenewJWT, TryCreateAccount, ConfirmAccount, RecoverPwd, ConfirmRecoverPwd}; diff --git a/src/apps/public/index.js b/src/apps/public/index.js index c2cd661..27cefe4 100644 --- a/src/apps/public/index.js +++ b/src/apps/public/index.js @@ -4,6 +4,7 @@ const { ROOT_PATH , LIB_PATH } = process.env; /// Router instance const router = require('express').Router(); +const account = require('./account/routes.js'); const cities = require('./cities/routes.js'); const countries = require('./countries/routes.js'); const metaData = require('./meta-data/routes.js'); @@ -17,6 +18,7 @@ const publicLoadAttachments = require('./public-load-attachments/routes.js'); const states = require('./states/routes.js'); const test = require('./test/routes.js'); +router.use('/account', account); router.use('/cities', cities); router.use('/countries', countries); router.use('/meta-data', metaData); diff --git a/src/config/apiConfig.json b/src/config/apiConfig.json index a1c46af..ff654fd 100644 --- a/src/config/apiConfig.json +++ b/src/config/apiConfig.json @@ -1,7 +1,9 @@ { "authentication": { + "pwdSecret":"Nx2g_IWo2Zt_LS$+", "jwtSecret":"9o3BBz0EsrwXliwEJ/SFuywZoN8=", - "jwtTimeout":720, + "jwtTimeout":24, + "jwtRenewalTimeout":720, "tokenSecret":"9Z'jMt|(h_f(&/S+zv.K", "jwtOptions": { "header": { diff --git a/src/config/apiConfig_local.json b/src/config/apiConfig_local.json index e4b4f71..c210a4e 100644 --- a/src/config/apiConfig_local.json +++ b/src/config/apiConfig_local.json @@ -1,7 +1,9 @@ { "authentication": { + "pwdSecret":"Nx2g_IWo2Zt_LS$+", "jwtSecret":"9o3BBz0EsrwXliwEJ/SFuywZoN8=", - "jwtTimeout":720, + "jwtTimeout":24, + "jwtRenewalTimeout":720, "tokenSecret":"9Z'jMt|(h_f(&/S+zv.K", "jwtOptions": { "header": { diff --git a/src/lib/Handlers/Account/index.js b/src/lib/Handlers/Account/index.js new file mode 100644 index 0000000..b4c9170 --- /dev/null +++ b/src/lib/Handlers/Account/index.js @@ -0,0 +1,58 @@ +'user strict'; +const { ROOT_PATH, API_CONFIG, MODELS_PATH, LIB_PATH } = process.env; +const apiConfig = require( `${ROOT_PATH}/${API_CONFIG}` ); +const { toSha256 } = require( `${ROOT_PATH}/${LIB_PATH}/Misc.js` ); +const UserModel = require( `${ROOT_PATH}/${MODELS_PATH}/users.model.js` ); + +const pwd_secret = apiConfig.authentication.pwdSecret; + +async function create_account( email, password, company_type ){ + let permissions; + if( company_type === "Shipper"){ + permissions = "role_shipper"; + }else{ + permissions = "role_carrier"; + } + let safe_password = toSha256( password + pwd_secret ); + const user = new UserModel({ + email, + password : safe_password, + permissions + }); + await user.save(); +} + +async function reset_password( email, password ){ + let safe_password = toSha256( password + pwd_secret ); + const user = await UserModel.findOne({ email }); + user.password = safe_password, + await user.save(); + return user; +} + +async function already_exists( email ){ + const user = await UserModel.findOne( { email } ); + if( !user ){ + return false; + }else{ + return true; + } +} + +async function login( email , password ){ + let safe_password = toSha256( password + pwd_secret ); + const user = await UserModel.findOne({ + email , password : safe_password + },{ password : 0 }); + return user; +} + +async function login_with_session_token( session_token ){ + const user = await UserModel.findOne({ + session_token, + session_token_exp : { $gte: new Date() } + },{ password : 0 }); + return user; +} + +module.exports = { create_account, already_exists, login, login_with_session_token, reset_password }; diff --git a/src/lib/Handlers/MailClient/SendGrid.handler.js b/src/lib/Handlers/MailClient/SendGrid.handler.js new file mode 100644 index 0000000..52f5395 --- /dev/null +++ b/src/lib/Handlers/MailClient/SendGrid.handler.js @@ -0,0 +1,66 @@ +'user strict'; +const { ROOT_PATH, API_CONFIG } = process.env; +const apiConfig = require( `${ROOT_PATH}/${API_CONFIG}` ); +const nodemailer = require("nodemailer"); +const SendGrid = require("nodemailer-sendgrid"); + +const SiteName = "ETA Viaporte"; + +const sendgridConfig = apiConfig.sendgrid; + +const transporter = nodemailer.createTransport( + SendGrid({ + host: sendgridConfig.HOST, + apiKey: sendgridConfig.API_KEY + }) +); + +async function sendMailTemplate( templateId, receiver, subject, content ){ + /**TODO: Remove in production */ + const default_mail = "testing@etaviaporte.com" + if( receiver.indexOf( default_mail ) >= 0 ){ + receiver = default_mail; + } + return await transporter.sendMail({ + from: sendgridConfig.FROM, + to: receiver, + subject: subject, + templateId: templateId, + dynamic_template_data: content + }); +} + +async function AccountVerifyEmail( receiver , content ){ + const templateId = "d-e9b7966303694964a64b6e4954e9715d"; + const subject = "[ETA] Account Verification"; + const content_to_send = { + project_name: SiteName, + user_name: content.user_name, + user_email: receiver, + OTP : content.OTP + }; + return await sendMailTemplate( templateId, receiver, subject, content_to_send ); +} + +async function AccountConfirmed( receiver, content ){ + const templateId = "d-4daaab1b85d443ceba38826f606e9931"; + const subject = "[ETA] Welcome to ETA"; + const content_to_send = { + user_name: content.user_name, + }; + return await sendMailTemplate( templateId, receiver, subject, content_to_send ); +} + +async function AccountPwdResetEmail( receiver, content ){ + const templateId = "d-e9b7966303694964a64b6e4954e9715d"; + const subject = "[ETA] Password Reset"; + const content_to_send = { + project_name: SiteName, + user_name: content.user_name, + user_email: receiver, + OTP : content.OTP + }; + return await sendMailTemplate( templateId, receiver, subject, content_to_send ); +} + +module.exports = { AccountVerifyEmail, AccountConfirmed, AccountPwdResetEmail }; diff --git a/src/lib/Handlers/MailClient/index.js b/src/lib/Handlers/MailClient/index.js new file mode 100644 index 0000000..897efc0 --- /dev/null +++ b/src/lib/Handlers/MailClient/index.js @@ -0,0 +1,43 @@ +'user strict'; +const { ROOT_PATH, HANDLERS_PATH, MODELS_PATH, API_CONFIG } = process.env; +const { AccountVerifyEmail, AccountConfirmed, AccountPwdResetEmail } = require('./SendGrid.handler'); + +const EMAIL_EVENTS={ + ACCOUNT_VERIFY:1, + ACCOUNT_CONFIRMED:2, + ACCOUNT_PWD_RESET:3, +} + +/** + * Send an email according to the event. + * @param eventId : string + * @param email_content : { string receiver, {*} content } + * @returns + */ +async function emailEvent( eventId, receiver , content ){ + switch( eventId ){ + case EMAIL_EVENTS.ACCOUNT_VERIFY: + { + return await AccountVerifyEmail( receiver, content ); + } + break; + case EMAIL_EVENTS.ACCOUNT_CONFIRMED: + { + return await AccountConfirmed( receiver, content ); + } + break; + case EMAIL_EVENTS.ACCOUNT_PWD_RESET: + { + return await AccountPwdResetEmail( receiver, content ); + } + break; + default: + { + throw new Error(`Email event not defined ${eventId}`); + } + break; + } + return await usersModel.findById( id , { password : 0 } ); +} + +module.exports = { emailEvent , EMAIL_EVENTS }; diff --git a/src/lib/Misc.js b/src/lib/Misc.js index 556d909..46c2be9 100644 --- a/src/lib/Misc.js +++ b/src/lib/Misc.js @@ -1,50 +1,30 @@ "use strict"; +const { ROOT_PATH, API_CONFIG } = process.env; +const apiConfig = require( `${ROOT_PATH}/${API_CONFIG}` ); +const crypto = require('crypto'); -function getPagination( query ){ - let limit = { - page : 0, - elements : 10 - }; - - if( query.page ){ - limit.page = parseInt( query.page ) || 0; - if( limit.page < 0 ){ - limit.page = 0; - } - } - if( query.elements ){ - limit.elements = parseInt( query.elements ) || 10; - /** Safe pagination limit */ - if( limit.elements > 1000 ){ - limit.elements = 1000; - } - else if( limit.elements < 0 ){ - limit.elements = 10; - } - } - return limit; +const secret = apiConfig.authentication.jwtSecret; +/** + * Convert string to sha256 string in hex + * @param {*} text + * @returns + */ +function toSha256( text ){ + return crypto.createHmac( "sha256" , "" ).update( text ).digest( 'hex' ); } -async function getPage( page, elements, model, filter=null, projection=null){ - const skip = elements * page; - const total = await model.count( filter ); - const list = await model.find( filter , projection, { skip : skip , limit : elements } ); - return { - total : total, - limit : elements, - skip : skip, - data : list +/** + * Generate string with fixed length with random content. + * Length is limited to 64 characters. + * @param {*} text + * @returns + */ +function genKey( len = 6 , key="" ){ + if( len >= 64 ){ + throw "invalid key len"; } + const complete_string = toSha256( key + new Date() + secret ); + return complete_string.substr(0 , len ); } -async function queryPage(page, elements, model, filter=null, projection=null){ - const skip = elements * page; - const total = await model.count( filter ); - return { - query : model.find( filter , projection, { skip : skip , limit : elements } ), - total : total, - skip : skip - }; -} - -module.exports = { getPagination , getPage, queryPage }; \ No newline at end of file +module.exports = { genKey , toSha256 }; \ No newline at end of file diff --git a/src/lib/Models/users.model.js b/src/lib/Models/users.model.js index 9568fcc..17a12ad 100644 --- a/src/lib/Models/users.model.js +++ b/src/lib/Models/users.model.js @@ -18,10 +18,10 @@ const schema = new Schema({ last_name: { type: String }, middle_name: { type: String }, email: { type: String, unique: true, lowercase: true }, - password: { type: String }, + password: { type: String , maxLength : 256 }, phone: { type: String }, phone2: { type: String }, - permissions: [{ type: String, default: 'role_admin' }], + permissions: [{ type: String, default: 'role_admin', enum : ['role_admin', 'role_shipper', 'role_carrier', 'role_driver' ] }], gender: { type: String }, address: { type: String }, dob: { type: String }, @@ -58,6 +58,9 @@ const schema = new Schema({ resetExpires: { type: Date }, // or a long integer resetAttempts: { type: Number }, + session_token : { type : String, maxLength : 256 }, + session_token_exp : { type: Date }, + is_hidden: { type: Boolean, default: false }, });