From 53b5b235bf5ee6b8520c8d9f9fb99717afa585e5 Mon Sep 17 00:00:00 2001 From: Rif'at Ahdi R <10791791+atrifat@users.noreply.github.com> Date: Tue, 10 Oct 2023 01:28:19 +0000 Subject: [PATCH] Add simple authentication support --- .env.example | 5 +- package-lock.json | 41 ++++++++++ package.json | 1 + src/index.mjs | 197 +++++++++++++++++++++++++++------------------- 4 files changed, 161 insertions(+), 83 deletions(-) diff --git a/.env.example b/.env.example index 1807399..fbd4213 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,7 @@ ENABLE_CACHE = false //Cache in ms CACHE_TTL = 300000 BACKEND_SERVER_LIST = http://localhost:3000,http://localhost:3001 -ROUND_ROBIN_STRATEGY = sequential \ No newline at end of file +ROUND_ROBIN_STRATEGY = sequential +// (Optional, Default: false) Set if you want to enable simple auth token +LB_ENABLE_AUTH_TOKEN = false +LB_AUTH_TOKEN = myapitokenchangethislater \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bb54efe..2fea576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "body-parser": "^1.20.2", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-bearer-token": "^2.4.0", "js-sha256": "^0.10.1", "lru-cache": "^10.0.1", "object-hash": "^3.0.0", @@ -160,6 +161,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -276,6 +297,26 @@ "node": ">= 0.10.0" } }, + "node_modules/express-bearer-token": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/express-bearer-token/-/express-bearer-token-2.4.0.tgz", + "integrity": "sha512-2+kRZT2xo+pmmvSY7Ma5FzxTJpO3kGaPCEXPbAm3GaoZ/z6FE4K6L7cvs1AUZwY2xkk15PcQw7t4dWjsl5rdJw==", + "dependencies": { + "cookie": "^0.3.1", + "cookie-parser": "^1.4.4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/express-bearer-token/node_modules/cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/express/node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", diff --git a/package.json b/package.json index 4c21bbd..ca8ef32 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "body-parser": "^1.20.2", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-bearer-token": "^2.4.0", "js-sha256": "^0.10.1", "lru-cache": "^10.0.1", "object-hash": "^3.0.0", diff --git a/src/index.mjs b/src/index.mjs index 76b830c..e7fa2fd 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -10,6 +10,7 @@ import { performance } from "node:perf_hooks"; import { exit } from "node:process"; import { handleFatalError } from "./util.mjs"; import { retry } from "@ultraq/promise-utils"; +import bearerToken from 'express-bearer-token'; // Load env variable from .env dotenv.config(); @@ -19,9 +20,11 @@ const ENABLE_CACHE = process.env.ENABLE_CACHE ? process.env.ENABLE_CACHE === 'tr const CACHE_TTL = process.env.CACHE_TTL ? parseInt(process.env.CACHE_TTL) : 300000; const BACKEND_SERVER_LIST = process.env.BACKEND_SERVER_LIST ? process.env.BACKEND_SERVER_LIST.split(',').map(s => s.trim()) : []; const ROUND_ROBIN_STRATEGY = process.env.ROUND_ROBIN_STRATEGY || 'sequential'; +const LB_ENABLE_AUTH_TOKEN = process.env.LB_ENABLE_AUTH_TOKEN ? process.env.LB_ENABLE_AUTH_TOKEN === 'true' : false; +const LB_AUTH_TOKEN = process.env.LB_AUTH_TOKEN || "myapitokenchangethislater"; if (BACKEND_SERVER_LIST.length == 0) { - handleFatalError(new Error("Backend server cannot be empty")); + handleFatalError(new Error("Backend server cannot be empty")); } // console.debug(BACKEND_SERVER_LIST); @@ -29,19 +32,19 @@ if (BACKEND_SERVER_LIST.length == 0) { // Only retry failed request maximum 2 times const retryStrategy = function (result, error, attempts) { - return !!error && attempts < 2 ? attempts * 250 : -1; + return !!error && attempts < 2 ? attempts * 250 : -1; } const responseCache = new LRUCache( - { - max: 500, - maxSize: 5000, - sizeCalculation: (value, key) => { - return value.toString().length; - }, - // how long to live in ms - ttl: CACHE_TTL, - } + { + max: 500, + maxSize: 5000, + sizeCalculation: (value, key) => { + return value.toString().length; + }, + // how long to live in ms + ttl: CACHE_TTL, + } ) const app = express(); @@ -51,15 +54,15 @@ const servers = BACKEND_SERVER_LIST; let availableServers; switch (ROUND_ROBIN_STRATEGY) { - case 'sequential': - availableServers = new SequentialRoundRobin(servers); - break; - case 'random': - availableServers = new RandomRoundRobin(servers); - break; - default: - availableServers = new SequentialRoundRobin(servers); - break; + case 'sequential': + availableServers = new SequentialRoundRobin(servers); + break; + case 'random': + availableServers = new RandomRoundRobin(servers); + break; + default: + availableServers = new SequentialRoundRobin(servers); + break; } const PORT = process.env.PORT || 8081; @@ -67,76 +70,106 @@ const PORT = process.env.PORT || 8081; // Whenever receive new request will forward to application server const loadBalancerHandler = async (req, res) => { - // Extract properties from request object - const { method, url, headers, body } = req; - - const ip = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress; - - const newHeaders = {}; - const skipHeadersKey = ["content-length", "host"]; - Object.keys(headers).forEach((key) => { - const existSkipHeaderKey = skipHeadersKey.includes(key.toLowerCase()); - if (!existSkipHeaderKey) newHeaders[key] = headers[key]; - }); - - console.debug(ip); - console.debug(headers); - // console.debug(newHeaders); - console.debug(url); - // exit(); + // Extract properties from request object + const { method, url, headers, body } = req; + + const ip = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress; + + const newHeaders = {}; + const skipHeadersKey = ["content-length", "host"]; + + if (LB_ENABLE_AUTH_TOKEN) { + // Don't forward current authorization header to backend + skipHeadersKey.push("authorization"); + } + Object.keys(headers).forEach((key) => { + const existSkipHeaderKey = skipHeadersKey.includes(key.toLowerCase()); + if (!existSkipHeaderKey) newHeaders[key] = headers[key]; + }); + + console.debug(ip); + console.debug(headers); + // console.debug(newHeaders); + console.debug(url); + // exit(); + + // Make request key from combination of hash from url, method, and body object hash + const req_key = sha256(`${url} ${method} ` + hash.sha1(body)); + + // Return cached response immediately if it exists + if (ENABLE_CACHE && responseCache.has(req_key)) { + console.debug("Get from cache"); + return res.send(responseCache.get(req_key)); + } + + // Select the server using round robin strategy to forward the request + const server = availableServers.next(); + console.debug("Connect to " + server.value); + + try { + const startTimeRequest = performance.now(); + + // Requesting to underlying application server + const response = await retry(() => axios({ + url: `${server.value}${url}`, + method: method, + headers: newHeaders, + data: body + }), retryStrategy); + + const endTimeRequest = performance.now(); + + console.debug("Request to " + server.value + " took " + (endTimeRequest - startTimeRequest) + " ms"); + + // Send cache if it is available + if (ENABLE_CACHE) responseCache.set(req_key, response.data); + + // Send back the response data from application server to client + res.status(response.status).header(response.headers).send(response.data); + } + catch (err) { + // Send back the error message + console.error(err); + const statusCode = err.response ? err.response.status : 500; + const message = err.response ? typeof err.response.data.message ? err.response.data.message : err.response.reason.message || err.response.code || err.message || "Server error!" : "Server error!"; + res.status(statusCode).header(err.response.headers).send(message); + } +} - // Make request key from combination of hash from url, method, and body object hash - const req_key = sha256(`${url} ${method} ` + hash.sha1(body)); +app.use(bodyparser.json({ limit: '5mb' })); - // Return cached response immediately if it exists - if (ENABLE_CACHE && responseCache.has(req_key)) { - console.debug("Get from cache"); - return res.send(responseCache.get(req_key)); +if (LB_ENABLE_AUTH_TOKEN) { + // Extract auth token if it is exist + app.use(bearerToken()); + + // Simple authentication middleware + const authMiddleware = function (req, res, next) { + if (LB_ENABLE_AUTH_TOKEN) { + const token = typeof req.token !== 'undefined' ? req.token : null; + if (!token) { + const error = new Error('Missing API token'); + error.statusCode = 401 + return res.status(401).json({ "message": error.message }); + } + + if (LB_AUTH_TOKEN !== token) { + const error = new Error('Invalid API token'); + error.statusCode = 401 + return res.status(401).json({ "message": error.message }); + } } + next(); + }; - // Select the server using round robin strategy to forward the request - const server = availableServers.next(); - console.debug("Connect to " + server.value); - - try { - const startTimeRequest = performance.now(); - - // Requesting to underlying application server - const response = await retry(() => axios({ - url: `${server.value}${url}`, - method: method, - headers: newHeaders, - data: body - }), retryStrategy); - - const endTimeRequest = performance.now(); - - console.debug("Request to " + server.value + " took " + (endTimeRequest - startTimeRequest) + " ms"); - - // Send cache if it is available - if (ENABLE_CACHE) responseCache.set(req_key, response.data); - - // Send back the response data from application server to client - res.status(response.status).header(response.headers).send(response.data); - } - catch (err) { - // Send back the error message - console.error(err); - const statusCode = err.response ? err.response.status : 500; - const message = err.response ? typeof err.response.data.message ? err.response.data.message : err.response.reason.message || err.response.code || err.message || "Server error!" : "Server error!"; - res.status(statusCode).header(err.response.headers).send(message); - } + app.use(authMiddleware); } - -app.use(bodyparser.json({ limit: '5mb' })); - // Pass any request to loadBalancerHandler app.use((req, res) => { loadBalancerHandler(req, res) }); // Listen on PORT 8080 app.listen(PORT, err => { - err ? - console.debug(`Failed to listen on PORT ${PORT}`) : - console.debug("Load Balancer Server " - + `listening on PORT ${PORT}`); + err ? + console.debug(`Failed to listen on PORT ${PORT}`) : + console.debug("Load Balancer Server " + + `listening on PORT ${PORT}`); });