diff --git a/package-lock.json b/package-lock.json index a26618af..da3ad1b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8434,6 +8434,17 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "mysql": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", + "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", + "requires": { + "bignumber.js": "9.0.0", + "readable-stream": "2.3.7", + "safe-buffer": "5.1.2", + "sqlstring": "2.3.1" + } + }, "nan": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", @@ -10548,6 +10559,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "sqlstring": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", + "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=" + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", diff --git a/package.json b/package.json index 33737358..5415ee76 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "mongoose": "^5.9.19", "mongoose-findorcreate": "^3.0.0", "mongoose-mongodb-errors": "0.0.2", + "mysql": "^2.18.1", "passport": "^0.4.1", "passport-facebook": "^3.0.0", "passport-github": "^1.1.0", diff --git a/src/middlewares/middleware.js b/src/middlewares/middleware.js index 058e869d..22f3cf86 100644 --- a/src/middlewares/middleware.js +++ b/src/middlewares/middleware.js @@ -61,29 +61,38 @@ const googleAuthProvider = (req, res, next) => const authProvider = (providerType) => { return async (req, res, next) => { - const admin = await Admin.findById(req.admin.id).populate("settings"); - const { successCallbackUrl, failureCallbackUrl } = admin.settings; + // Temporary check during experimentation to check whether or not the settingsMiddleware + // is enabled. If its enabled then we don't have to use our backup settings + // stored in our database. + if (!req.settings) { + console.log(`No settings from settingsMiddleware: ${req.settings}`); + admin = await Admin.findById(req.admin.id).populate("settings"); + req.settings = admin.settings; + } else { + console.log(`Received settings from settingsMiddleware: ${req.settings}`); + } let provider; let providerEnabled = false; const callbacksAvailable = - successCallbackUrl !== null && failureCallbackUrl !== null; + req.settings.successCallbackUrl !== null && + req.settings.failureCallbackUrl !== null; switch (providerType) { case TWITTER_PROVIDER: - provider = admin.settings.twitterAuthProvider; + provider = req.settings.settings.twitterAuthProvider; providerEnabled = provider && provider.key; break; case FACEBOOK_PROVIDER: - provider = admin.settings.facebookAuthProvider; + provider = req.settings.facebookAuthProvider; providerEnabled = provider && provider.appID; break; case GITHUB_PROVIDER: - provider = admin.settings.githubAuthProvider; + provider = req.settings.githubAuthProvider; providerEnabled = provider && provider.clientID; break; case GOOGLE_PROVIDER: - provider = admin.settings.googleAuthProvider; + provider = req.settings.googleAuthProvider; providerEnabled = provider && provider.clientID; break; @@ -95,8 +104,8 @@ const authProvider = (providerType) => { if (providerEnabled && callbacksAvailable) { req.provider = provider; - req.successCallbackUrl = successCallbackUrl; - req.failureCallbackUrl = failureCallbackUrl; + req.successCallbackUrl = req.settings.successCallbackUrl; + req.failureCallbackUrl = req.settings.failureCallbackUrl; next(); } else { diff --git a/src/middlewares/settings.js b/src/middlewares/settings.js new file mode 100644 index 00000000..d70576ce --- /dev/null +++ b/src/middlewares/settings.js @@ -0,0 +1,176 @@ +require("dotenv").config(); +const mysql = require("mysql"); +const CustomResponse = require("../utils/response"); +const settingsHandler = require("../utils/settingsHandler"); +const getMockSettings = require("../utils/mocks/settings"); +const log = require("debug")("log"); + +const dbConfig = { + host: process.env.MICROAPI_DB_HOST, + user: process.env.MICROAPI_DB_USER, + password: process.env.MICROAPI_DB_PASSWORD, + database: process.env.MICROAPI_DB_DATABASE, + port: process.env.MICROAPI_DB_PORT, +}; + +const MAX_CONNECTION_TRIES = 5; + +//get settings from external DB or endpoint +//function might be modified to accomodate both sources +const getSettings = async (apiKey) => { + //fool linter + log(apiKey); + + let connection; + let connectionTries = 0; + + let outsideResolve; + let outsideReject; + const promise = new Promise((resolve, reject) => { + outsideResolve = resolve; + outsideReject = reject; + }); + + function getConnectionErrorResponse(err) { + let message; + + if (connectionTries === MAX_CONNECTION_TRIES) { + message = "Maximum connection tries reached"; + } else { + console.log(err); + message = err; + } + return { errors: [{ message }] }; + } + + function handleDisconnect() { + connectionTries += 1; + // Recreate the connection, since the old one cannot be reused. + connection = mysql.createConnection(dbConfig); + + connection.connect((err) => { + // The server is either down or restarting (takes a while sometimes). + if (err) { + if (connectionTries <= MAX_CONNECTION_TRIES) { + console.log("error when connecting to db:", err.message); + // We introduce a delay before attempting to reconnect, to avoid a hot loop, + // and to allow our node script to process asynchronous requests in the meantime. + setTimeout(handleDisconnect, 2000); + } else { + const errorResponse = getConnectionErrorResponse(err); + outsideReject(errorResponse); + } + } else { + // Query the database for the project belonging to the project + connection.query( + "SELECT * FROM user_dashboard_project", + (err, results) => { + if (err) { + if ( + err.code === "PROTOCOL_CONNECTION_LOST" || + connectionTries <= MAX_CONNECTION_TRIES + ) { + handleDisconnect(); + } + console.log("Query error: ", err); + const errorResponse = getConnectionErrorResponse(err); + outsideReject(errorResponse); + } + + console.log(results); + //mock the request for now with mocksettings + //settings need to come from source + //validate the settings by matching against predefined schema + // const settings = settingsHandler.parseSettings(getMockSettings(), true); + + outsideResolve(results); + } + ); + } + }); + + connection.on("error", (err) => { + if (err) { + console.log("db error", err); + if ( + err.code === "PROTOCOL_CONNECTION_LOST" || + connectionTries <= MAX_CONNECTION_TRIES + ) { + // Connection to the MySQL server is usually lost due to either server restart, or a + // connnection idle timeout (the wait_timeout server variable configures this) + handleDisconnect(); + } else { + const errorResponse = getConnectionErrorResponse(err); + outsideReject(errorResponse); + } + } + }); + } + + handleDisconnect(); + + return promise; +}; + +const settingsMiddleware = async (req, res, next) => { + /* In multi-tenant app, projectID is retreived from API key in a custom HTTP header + ** For now we stick with multi-tenant and we will customize this to cater for ** + ** single tenancy architecture in time where projectIDs are irrelevant ** + ** In retrospect, expiry of API keys should be from MicroAPI, ** + ** so making a request for settings with an invalid API key will be rejected ** + */ + try { + // we are calling our custom HTTP header X-MicroAPI-ProjectKey + const apiKey = req.headers["x-microapi-projectkey"]; + if (!apiKey) { + // Temporarily optional as this is an experimental feature. + next(); + // res + // .status(401) + // .json( + // CustomResponse( + // "UnauthorizedError", + // { statusCode: 401, message: "No API key found" }, + // false + // ) + // ); + } + + // get settings from parent DB/source + const settings = await getSettings(apiKey); + if (settings.errors) { + res + .status(400) + .json( + CustomResponse( + "BadRequestError", + { statusCode: 400, message: settings.errors[0].message }, + false + ) + ); + } + + console.log(settings); + + //attach setting to request body + req.settings = settings; + // req.projectId = projectId; + + //pass to next middleware + next(); + } catch (error) { + console.log(error); + res.status(500).json( + CustomResponse( + "InternalServerError", + { + statusCode: 500, + message: "Something went wrong, please try again later", + }, + false + ) + ); + } +}; + +module.exports = settingsMiddleware; diff --git a/src/routes/googleLogin.js b/src/routes/googleLogin.js index 8f621fa2..74349ee7 100644 --- a/src/routes/googleLogin.js +++ b/src/routes/googleLogin.js @@ -3,6 +3,7 @@ const passport = require("passport"); const url = require("url"); const { google } = require("googleapis"); const CustomResponse = require("../utils/response"); +const settingsMiddleware = require("../middlewares/settings"); const createGoogleStrategy = require("../config/passport/googleStrategy"); const { authorizeUser, @@ -13,7 +14,7 @@ const route = express.Router(); // The credentials of all who are currently undergoing Google's authentication flow. const activeCredentials = {}; -route.get("/", authorizeUser, googleAuthProvider, async (req, res) => { +route.get("/", settingsMiddleware, authorizeUser, googleAuthProvider, async (req, res) => { try { const oauth2Client = new google.auth.OAuth2( req.provider.clientID, diff --git a/src/services/adminAuth.js b/src/services/adminAuth.js index 71a38341..81711771 100644 --- a/src/services/adminAuth.js +++ b/src/services/adminAuth.js @@ -5,7 +5,7 @@ const Settings = require("../models/settings"); const { CustomError } = require("../utils/CustomError"); const RandomString = require("randomstring"); const { sendForgotPasswordMail } = require("../EmailFactory/index"); -const { settingsSchema } = require("../utils/settingsHandler"); +const settingsHandler = require("../utils/settingsHandler"); class AdminService { async register(body) { @@ -82,7 +82,7 @@ class AdminService { // New API KEY for admin const message = "Settings retrieved successfully"; return { - data: settingsSchema, + data: settingsHandler.settingsSchema, message: message, }; } diff --git a/src/utils/mocks/settings.js b/src/utils/mocks/settings.js new file mode 100644 index 00000000..0eb2e377 --- /dev/null +++ b/src/utils/mocks/settings.js @@ -0,0 +1,120 @@ +const getMockSettings = () => [ + { + setting_name: "Email Verification Callback", + setting_type: "String", + setting_key: "emailVerifyCallback", + setting_required: true, + setting_value: "/emailCallback", + }, + { + setting_name: "Password Reset Callback", + setting_type: "String", + setting_key: "passwordResetCallback", + setting_required: true, + setting_value: "/passwordResetCallback", + }, + { + setting_name: "Login Success Callback", + setting_type: "String", + setting_key: "successCallback", + setting_required: true, + setting_value: "successCallback", + }, + { + setting_name: "MongoDB URI", + setting_type: "String", + setting_key: "mongoDbUri", + setting_required: true, + setting_value: "dbUriString", + }, + { + setting_name: "Facebook Credentials", + setting_type: "Array", + setting_key: "facebookAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Facebook Application ID", + setting_type: "String", + setting_key: "appID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Facebook Application Secret", + setting_type: "String", + setting_key: "appSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Twitter Credentials", + setting_type: "Array", + setting_key: "twitterAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Twitter Consumer Key", + setting_type: "String", + setting_key: "key", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Twitter Consumer Secret", + setting_type: "String", + setting_key: "secret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Github Credentials", + setting_type: "Array", + setting_key: "githubAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Github Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Github Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Google Credentials", + setting_type: "Array", + setting_key: "googleAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Google Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Google Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, +]; + +module.exports = getMockSettings; diff --git a/src/utils/settingsHandler.js b/src/utils/settingsHandler.js index cc91c73b..393a2792 100644 --- a/src/utils/settingsHandler.js +++ b/src/utils/settingsHandler.js @@ -1,4 +1,4 @@ -exports.settingsSchema = [ +const settingsSchema = [ { setting_name: "Email Verification Callback", setting_type: "String", @@ -47,13 +47,6 @@ exports.settingsSchema = [ setting_value: null, setting_required: true, }, - { - setting_name: "Login Success Callback", - setting_type: "String", - setting_key: "successCallback", - setting_required: false, - setting_value: null, - }, ], }, { @@ -124,14 +117,59 @@ exports.settingsSchema = [ }, ]; -exports.parseSettings = (data) => { - return data.reduce((acc, item) => { +const parseSettings = ( + data, + validate = false, + errors = [], + isRecursion = false, + parentKey = "" +) => { + const settings = data.reduce((acc, item) => { if (item["setting_type"] == "Array") { - const newTemp = parseSettings(item.setting_value); + const newTemp = parseSettings( + item.setting_value, + item["setting_required"] && validate, //only validate if parent is required + errors, + true, + item["setting_key"] + ); return { ...acc, [item["setting_key"]]: newTemp }; } const temp = acc; temp[item.setting_key] = item.setting_value; + + //do validation here + if (validate) { + if (item["setting_required"] && !item["setting_value"]) { + errors.push( + `RequiredError: ${parentKey ? parentKey + "." : ""}${ + item["setting_key"] + } is a required setting` + ); + } else if ( + item["setting_type"].toLowerCase() !== + (typeof item["setting_value"]).toLowerCase() + ) { + errors.push( + `TypeError: ${parentKey ? parentKey + "." : ""}${ + item["setting_key"] + } should be a/an ${item["setting_type"]}` + ); + } + } + return { ...acc, ...temp }; }, {}); + + //Don't attach errors in recursion + if (validate && !isRecursion && errors.length) { + settings.errors = errors; + } + + return settings; +}; + +module.exports = { + settingsSchema, + parseSettings, };