From 13b41a50c678659772db391f6ec1694804a81e4f Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 4 Feb 2020 16:12:38 -0800 Subject: [PATCH 01/17] Editing comments and notes --- package-lock.json | 64 ++++++++++++++++++++++++++ package.json | 6 ++- src/app.js | 5 ++- src/middleware/auth.js | 3 ++ src/middleware/jwt-auth.js | 2 + src/middleware/route-ratelimit.js | 2 +- test/v3/rate-limits.js | 74 ------------------------------- 7 files changed, 78 insertions(+), 78 deletions(-) diff --git a/package-lock.json b/package-lock.json index c00d725..9608aa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2256,6 +2256,11 @@ "wrap-ansi": "^5.1.0" } }, + "cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3041,6 +3046,11 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "denque": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", + "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -5690,6 +5700,22 @@ "p-is-promise": "^3.0.0" } }, + "ioredis": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.14.1.tgz", + "integrity": "sha512-94W+X//GHM+1GJvDk6JPc+8qlM7Dul+9K+lg3/aHixPN7ZGkW6qlvX0DG6At9hWtH2v3B32myfZqWoANUJYGJA==", + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.1.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "redis-commands": "1.5.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.0.1" + } + }, "ipaddr.js": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", @@ -6473,12 +6499,22 @@ "integrity": "sha1-+CbJtOKoUR2E46yinbBeGk87cqk=", "dev": true }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + }, "lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=", "dev": true }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, "lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -12165,6 +12201,11 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, + "rate-limiter-flexible": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-1.3.2.tgz", + "integrity": "sha512-f+xNvGn+52G4nZVok9VB3LTE1kfDmqbnWKRayX5n2k/LEQ7doWrYvzmzFVh7ltmLgwEdEzoYwxGaXlfCjFg4Ag==" + }, "raw-body": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", @@ -12332,6 +12373,24 @@ "esprima": "~4.0.0" } }, + "redis-commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz", + "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, "referrer-policy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz", @@ -13628,6 +13687,11 @@ } } }, + "standard-as-callback": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.0.1.tgz", + "integrity": "sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg==" + }, "standard-engine": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/standard-engine/-/standard-engine-8.0.1.tgz", diff --git a/package.json b/package.json index 8d848f3..7ea61fb 100644 --- a/package.json +++ b/package.json @@ -15,15 +15,15 @@ "test:temp": "export NETWORK=mainnet && mocha --timeout 25000 test/v3/blockchain.js", "test:integration": "mocha test/v3/integration", "coverage": "nyc report --reporter=text-lcov | coveralls", - "coverage:report": "export NETWORK=testnet && nyc --reporter=html mocha test/v2/", + "coverage:report": "export NETWORK=mainnet && nyc --reporter=html mocha --timeout 25000 test/v3/", "docs": "./node_modules/.bin/apidoc -i src/routes/v3 -o docs" }, "engines": { "node": ">=10.15.1" }, "dependencies": { - "apidoc": "^0.20.0", "@chris.troutner/bch-js": "^2.0.0", + "apidoc": "^0.20.0", "axios": "^0.19.0", "body-parser": "^1.18.3", "cookie-parser": "~1.4.3", @@ -34,6 +34,7 @@ "express-basic-auth": "^1.1.3", "express-rate-limit": "^5.0.0", "helmet": "^3.21.2", + "ioredis": "^4.14.1", "level": "^6.0.0", "mkdirp": "^1.0.0", "mocha": "^7.0.1", @@ -44,6 +45,7 @@ "passport-http": "^0.3.0", "pg": "^7.11.0", "pg-hstore": "^2.3.2", + "rate-limiter-flexible": "^1.3.2", "strftime": "^0.10.0", "winston": "^3.2.1", "winston-daily-rotate-file": "^4.0.0" diff --git a/src/app.js b/src/app.js index 5e4dc52..95597a8 100644 --- a/src/app.js +++ b/src/app.js @@ -78,11 +78,14 @@ const v3prefix = "v3" app.use(`/${v3prefix}/`, jwtAuth.getTokenFromHeaders) // Instantiate the authorization middleware, used to implement pro-tier rate limiting. +// Handles Anonymous and Basic Authorization schemes used by passport.js const auth = new AuthMW() app.use(`/${v3prefix}/`, auth.mw()) // Rate limit on all v3 routes -app.use(`/${v3prefix}/`, routeRateLimit) // Establish and enforce rate limits. +// Establish and enforce rate limits. +app.use(`/${v3prefix}/`, routeRateLimit) + app.use(`/${v3prefix}/` + `health-check`, healthCheckV3) app.use(`/${v3prefix}/` + `blockchain`, blockchainV3.router) app.use(`/${v3prefix}/` + `control`, controlV3.router) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 307a583..77510ff 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,4 +1,7 @@ /* + CT 2/4/20 Note: This library handles anonymous and Basic auth. This library + can be phased out with the chage to JWT tokens and the new rate-limit library. + Handle authorization for bypassing rate limits. 1) Default is 'Anonymous Authentication', which unlocks the freemimum tier by diff --git a/src/middleware/jwt-auth.js b/src/middleware/jwt-auth.js index 6880ea0..27b90cd 100644 --- a/src/middleware/jwt-auth.js +++ b/src/middleware/jwt-auth.js @@ -1,4 +1,6 @@ /* + This is a middleware library for handling and processing JWT tokens. + This middleware inspects the request header for a JWT token. If found, will populate req.locals.jwtToken with the JWT token. */ diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index cdad7a6..3dd8b6a 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -159,7 +159,7 @@ const routeRateLimit = async function(req, res, next) { uniqueRateLimits[route](req, res, next) } -// This function returns the an object with proLimit and apiLevel properties. +// This function returns an object with proLimit and apiLevel properties. // It does fine-grane analysis on the data coming from the auth servers and // uses its output to adjust rate limits on-the-fly based on the users // permission level. diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index 50d7ca6..3753ccc 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -71,80 +71,6 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(req.locals.jwtToken, jwt) }) }) - - // TODO: This code has been refactored and these unit tests no longer apply. - // I think I forgot to create new unit tests to reflect the change in code though. - /* - describe("#routeAccess", () => { - it("should do nothing if req.locals.jwtToken is undefined", () => { - // Initialize req.locals - req.locals = { - proLimit: false, - apiLevel: 0 - } - req.url = "/insight/address/details" - - // Reset the history of the stub and assert it has not been called. - res.status.resetHistory() - assert.equal(res.status.called, false, "stub history reset") - - jwtAuth.routeAccess(req, res, next) - - // console.log(`req.locals: ${JSON.stringify(req.locals, null, 2)}`) - assert.equal( - res.status.called, - false, - "stub should NOT have been called." - ) - }) - - it("should throw error if full-node tier tries to access indexer", () => { - // Initialize req.locals - req.locals = { - proLimit: true, - apiLevel: 10, - jwtToken: jwt - } - req.url = "/insight/address/details" - - try { - res.status.resetHistory() - assert.equal(res.status.called, false, "stub history reset") - - jwtAuth.routeAccess(req, res, next) - - assert.equal(res.status.called, true, "stub should have been called.") - } catch (err) { - console.log(`caught error: `, err) - } - }) - - it("should allow indexer tier tries access indexer endpoints", () => { - // Initialize req.locals - req.locals = { - proLimit: true, - apiLevel: 20, - jwtToken: jwt - } - req.url = "/insight/address/details" - - try { - res.status.resetHistory() - assert.equal(res.status.called, false, "stub history reset") - - jwtAuth.routeAccess(req, res, next) - - assert.equal( - res.status.called, - false, - "stub should NOT have been called." - ) - } catch (err) { - console.log(`caught error: `, err) - } - }) - }) - */ }) describe("#routeRateLimit", () => { From 5ecc321b3e958bdab881bcf6b90620587bf7620f Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 4 Feb 2020 16:35:31 -0800 Subject: [PATCH 02/17] fix(rate-limit.js): Refactored to use JS Class --- src/app.js | 6 +- src/middleware/route-ratelimit.js | 304 +++++++++++++++-------------- test/v3/integration/rate-limits.js | 2 +- test/v3/rate-limits.js | 33 ++-- 4 files changed, 181 insertions(+), 164 deletions(-) diff --git a/src/app.js b/src/app.js index 95597a8..1a972f0 100644 --- a/src/app.js +++ b/src/app.js @@ -3,7 +3,9 @@ const express = require("express") // Middleware -const { routeRateLimit } = require("./middleware/route-ratelimit") +// const { routeRateLimit } = require("./middleware/route-ratelimit") +const RateLimits = require("./middleware/route-ratelimit") +const rateLimits = new RateLimits() const path = require("path") const logger = require("morgan") @@ -84,7 +86,7 @@ app.use(`/${v3prefix}/`, auth.mw()) // Rate limit on all v3 routes // Establish and enforce rate limits. -app.use(`/${v3prefix}/`, routeRateLimit) +app.use(`/${v3prefix}/`, rateLimits.routeRateLimit) app.use(`/${v3prefix}/` + `health-check`, healthCheckV3) app.use(`/${v3prefix}/` + `blockchain`, blockchainV3.router) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index 3dd8b6a..f8f3935 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -32,169 +32,177 @@ const maxRequests = process.env.RATE_LIMIT_MAX_REQUESTS // Unique route mapped to its rate limit const uniqueRateLimits = {} -const routeRateLimit = async function(req, res, next) { - // Disable rate limiting if 0 passed from RATE_LIMIT_MAX_REQUESTS - if (maxRequests === 0) return next() +let _this - // Create a res.locals object if not passed in. - if (!req.locals) { - req.locals = { - // default values - jwtToken: "", - proLimit: false, - apiLevel: 0 - } +class RateLimits { + constructor() { + _this = this } - // Warn if JWT_AUTH_SERVER env var is not set. - const authServer = process.env.JWT_AUTH_SERVER - if (!authServer || authServer === "") { - console.warn( - "JWT_AUTH_SERVER env var is not set. JWT tokens not being evaluated." - ) - } else { - // If a JWT token is passed in, validate it and enable pro-tier rate limits - // if it's valid. - if (req.locals.jwtToken) { - // console.log(`req.locals.jwtToken: ${req.locals.jwtToken}`) + async routeRateLimit(req, res, next) { + // Disable rate limiting if 0 passed from RATE_LIMIT_MAX_REQUESTS + if (maxRequests === 0) return next() - // URL for the auth server. - const path = `${authServer}apitoken/isvalid/${req.locals.jwtToken}` - - // Ask Auth server if the JWT token is valid. - // Get the API level for this user. - let jwtInfo = await axios.get(path) - jwtInfo = jwtInfo.data - // console.log(`jwtInfo: ${JSON.stringify(jwtInfo, null, 2)}`) - - // If JWT if valid, evaluate the API level for the user. - if (jwtInfo.isValid) { - // Set fine-grain permissions for each user based on the JWT token. - const userPermissions = evalUserPermissioins(req, jwtInfo) - // console.log( - // `userPermissions: ${JSON.stringify(userPermissions, null, 2)}` - // ) - - req.locals.proLimit = userPermissions.proLimit - req.locals.apiLevel = userPermissions.apiLevel + // Create a res.locals object if not passed in. + if (!req.locals) { + req.locals = { + // default values + jwtToken: "", + proLimit: false, + apiLevel: 0 } } - } - // Current route - const rateLimitTier = req.locals.proLimit ? "PRO" : "BASIC" - const path = req.baseUrl + req.path + // Warn if JWT_AUTH_SERVER env var is not set. + const authServer = process.env.JWT_AUTH_SERVER + if (!authServer || authServer === "") { + console.warn( + "JWT_AUTH_SERVER env var is not set. JWT tokens not being evaluated." + ) + } else { + // If a JWT token is passed in, validate it and enable pro-tier rate limits + // if it's valid. + if (req.locals.jwtToken) { + // console.log(`req.locals.jwtToken: ${req.locals.jwtToken}`) - // Create a unique string as a route identifier. - const route = - rateLimitTier + - req.method + - req.locals.apiLevel + // Generates new rate limit when user upgrades JWT token. - path - .split("/") - .slice(0, 4) - .join("/") - //console.log(`route identifier: ${JSON.stringify(route, null, 2)}`) + // URL for the auth server. + const path = `${authServer}apitoken/isvalid/${req.locals.jwtToken}` - // console.log(`req.locals: ${JSON.stringify(req.locals, null, 2)}`) + // Ask Auth server if the JWT token is valid. + // Get the API level for this user. + let jwtInfo = await axios.get(path) + jwtInfo = jwtInfo.data + // console.log(`jwtInfo: ${JSON.stringify(jwtInfo, null, 2)}`) - // This boolean value is passed from the auth.js middleware. - const proRateLimits = req.locals.proLimit + // If JWT if valid, evaluate the API level for the user. + if (jwtInfo.isValid) { + // Set fine-grain permissions for each user based on the JWT token. + const userPermissions = _this.evalUserPermissioins(req, jwtInfo) + // console.log( + // `userPermissions: ${JSON.stringify(userPermissions, null, 2)}` + // ) - // console.log(`proRateLimits: ${proRateLimits}`) - - // Pro level rate limits - if (proRateLimits || proRateLimits === 0) { - // TODO: replace the console.logs with calls to our logging system. - // console.log(`applying pro-rate limits`) - - let PRO_RPM = 10 // Default value for free tier - if (req.locals.apiLevel > 0) PRO_RPM = 100 // RPM for paid tiers. - - // console.log(`PRO_RPM: ${PRO_RPM}, apiLevel: ${req.locals.apiLevel}`) - - // Create new RateLimit if none exists for this route - if (!uniqueRateLimits[route]) { - uniqueRateLimits[route] = new RateLimit({ - windowMs: 60 * 1000, // 1 minute window - delayMs: 0, // disable delaying - full speed until the max limit is reached - max: PRO_RPM, // start blocking after this many requests per minute - handler: function(req, res) { - //console.log(`pro-tier rate-handler triggered.`) - - res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 - return res.json({ - error: `Too many requests. Limits are ${PRO_RPM} requests per minute. Increase rate limits at https://account.bchjs.cash` - }) + req.locals.proLimit = userPermissions.proLimit + req.locals.apiLevel = userPermissions.apiLevel } - }) - } - - // Freemium level rate limits - } else { - // TODO: replace the console.logs with calls to our logging system. - // console.log(`applying freemium limits`) - - // Create new RateLimit if none exists for this route - if (!uniqueRateLimits[route]) { - uniqueRateLimits[route] = new RateLimit({ - windowMs: 60 * 1000, // 1 minute window - delayMs: 0, // disable delaying - full speed until the max limit is reached - max: maxRequests, // start blocking after maxRequests - handler: function(req, res) { - //console.log(`freemium rate-handler triggered.`) - - res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 - return res.json({ - error: `Too many requests. Your limits are currently ${maxRequests} requests per minute. Increase rate limits at https://account.bchjs.cash` - }) - } - }) - } - } - - //console.log(`calling uniqueRateLimits() on this route: ${route}`) - - // Call rate limit for this route - uniqueRateLimits[route](req, res, next) -} - -// This function returns an object with proLimit and apiLevel properties. -// It does fine-grane analysis on the data coming from the auth servers and -// uses its output to adjust rate limits on-the-fly based on the users -// permission level. -function evalUserPermissioins(req, authData) { - // console.log(`authData: ${JSON.stringify(authData, null, 2)}`) - - // Return object with default values - const retObj = { - proLimit: authData.isValid, - apiLevel: authData.apiLevel - } - - // if apiLevel = 0 (free tier), then return the default values. - if (retObj.apiLevel === 0) return retObj - - const level20Routes = ["insight", "bitcore", "blockbook"] - - const locals = req.locals - // console.log(`locals: ${JSON.stringify(locals, null, 2)}`) - const url = req.url - // console.log(`url: ${JSON.stringify(url, null, 2)}`) - - if (authData.apiLevel < 20) { - // Loop through the routes that are not accessible to this tier. - for (let i = 0; i < level20Routes.length; i++) { - // If the requested route is for a higher tier, - // revert to anonymous level permissions. - if (url.indexOf(level20Routes[i]) > -1) { - retObj.proLimit = false - retObj.apiLevel = 0 } } + + // Current route + const rateLimitTier = req.locals.proLimit ? "PRO" : "BASIC" + const path = req.baseUrl + req.path + + // Create a unique string as a route identifier. + const route = + rateLimitTier + + req.method + + req.locals.apiLevel + // Generates new rate limit when user upgrades JWT token. + path + .split("/") + .slice(0, 4) + .join("/") + //console.log(`route identifier: ${JSON.stringify(route, null, 2)}`) + + // console.log(`req.locals: ${JSON.stringify(req.locals, null, 2)}`) + + // This boolean value is passed from the auth.js middleware. + const proRateLimits = req.locals.proLimit + + // console.log(`proRateLimits: ${proRateLimits}`) + + // Pro level rate limits + if (proRateLimits || proRateLimits === 0) { + // TODO: replace the console.logs with calls to our logging system. + // console.log(`applying pro-rate limits`) + + let PRO_RPM = 10 // Default value for free tier + if (req.locals.apiLevel > 0) PRO_RPM = 100 // RPM for paid tiers. + + // console.log(`PRO_RPM: ${PRO_RPM}, apiLevel: ${req.locals.apiLevel}`) + + // Create new RateLimit if none exists for this route + if (!uniqueRateLimits[route]) { + uniqueRateLimits[route] = new RateLimit({ + windowMs: 60 * 1000, // 1 minute window + delayMs: 0, // disable delaying - full speed until the max limit is reached + max: PRO_RPM, // start blocking after this many requests per minute + handler: function(req, res) { + //console.log(`pro-tier rate-handler triggered.`) + + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Too many requests. Limits are ${PRO_RPM} requests per minute. Increase rate limits at https://account.bchjs.cash` + }) + } + }) + } + + // Freemium level rate limits + } else { + // TODO: replace the console.logs with calls to our logging system. + // console.log(`applying freemium limits`) + + // Create new RateLimit if none exists for this route + if (!uniqueRateLimits[route]) { + uniqueRateLimits[route] = new RateLimit({ + windowMs: 60 * 1000, // 1 minute window + delayMs: 0, // disable delaying - full speed until the max limit is reached + max: maxRequests, // start blocking after maxRequests + handler: function(req, res) { + //console.log(`freemium rate-handler triggered.`) + + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Too many requests. Your limits are currently ${maxRequests} requests per minute. Increase rate limits at https://account.bchjs.cash` + }) + } + }) + } + } + + //console.log(`calling uniqueRateLimits() on this route: ${route}`) + + // Call rate limit for this route + uniqueRateLimits[route](req, res, next) } - return retObj + // This function returns an object with proLimit and apiLevel properties. + // It does fine-grane analysis on the data coming from the auth servers and + // uses its output to adjust rate limits on-the-fly based on the users + // permission level. + evalUserPermissioins(req, authData) { + // console.log(`authData: ${JSON.stringify(authData, null, 2)}`) + + // Return object with default values + const retObj = { + proLimit: authData.isValid, + apiLevel: authData.apiLevel + } + + // if apiLevel = 0 (free tier), then return the default values. + if (retObj.apiLevel === 0) return retObj + + const level20Routes = ["insight", "bitcore", "blockbook"] + + const locals = req.locals + // console.log(`locals: ${JSON.stringify(locals, null, 2)}`) + const url = req.url + // console.log(`url: ${JSON.stringify(url, null, 2)}`) + + if (authData.apiLevel < 20) { + // Loop through the routes that are not accessible to this tier. + for (let i = 0; i < level20Routes.length; i++) { + // If the requested route is for a higher tier, + // revert to anonymous level permissions. + if (url.indexOf(level20Routes[i]) > -1) { + retObj.proLimit = false + retObj.apiLevel = 0 + } + } + } + + return retObj + } } -module.exports = { routeRateLimit } +module.exports = RateLimits diff --git a/test/v3/integration/rate-limits.js b/test/v3/integration/rate-limits.js index 99ba139..4142369 100644 --- a/test/v3/integration/rate-limits.js +++ b/test/v3/integration/rate-limits.js @@ -18,7 +18,7 @@ util.inspect.defaultOptions = { depth: 1 } const SERVER = `http://localhost:3000/v3/` const TEST_JWT = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVkYTc5ZDk4OTYyMjRjNjM2MmQwYzkwMiIsImlhdCI6MTU3MTUzOTU1MSwiZXhwIjoxNTc0MTMxNTUxfQ.PfPW_Z2NYT1O2zUHXopcz2aLGHSGudaKOIGnt7SuAi4" + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVlM2EwNDE1ZWIyOWE5NjJkYTI3MDhiNCIsImFwaUxldmVsIjowLCJyYXRlTGltaXQiOjEwLCJpYXQiOjE1ODA4NjA0NjcsImV4cCI6MTU4MzQ1MjQ2N30.fuY5S-YrF0J11h5uyMjPe7wiVkYRnIyXi4dL9-V-C6pLJm33p0dSq_pSheVVWw78n5kAvL_9kFHngbnmQiOJYQ" describe("#rate limits", () => { it("should get control/getNetworkInfo() with no auth", async () => { diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index 3753ccc..3ded94a 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -12,7 +12,10 @@ util.inspect.defaultOptions = { depth: 1 } const { mockReq, mockRes, mockNext } = require("./mocks/express-mocks") // Libraries under test -let rateLimitMiddleware = require("../../src/middleware/route-ratelimit") +const RateLimits = require("../../src/middleware/route-ratelimit") +const rateLimits = new RateLimits() +let rateLimitMiddleware = rateLimits.routeRateLimit + const controlRoute = require("../../src/routes/v3/full-node/control") const jwtAuth = require("../../src/middleware/jwt-auth") @@ -74,6 +77,7 @@ describe("#route-ratelimits & jwt-auth", () => { }) describe("#routeRateLimit", () => { + rateLimitMiddleware = new RateLimits() let routeRateLimit = rateLimitMiddleware.routeRateLimit const getInfo = controlRoute.testableComponents.getInfo @@ -112,10 +116,11 @@ describe("#route-ratelimits & jwt-auth", () => { it("should NOT trigger rate-limit for free-tier at 5 RPM", async () => { // Clear the require cache before running this test. - delete require.cache[ - require.resolve("../../src/middleware/route-ratelimit") - ] - rateLimitMiddleware = require("../../src/middleware/route-ratelimit") + // delete require.cache[ + // require.resolve("../../src/middleware/route-ratelimit") + // ] + // rateLimitMiddleware = require("../../src/middleware/route-ratelimit") + rateLimitMiddleware = new RateLimits() routeRateLimit = rateLimitMiddleware.routeRateLimit req.baseUrl = "/v3" @@ -167,10 +172,11 @@ describe("#route-ratelimits & jwt-auth", () => { it("should NOT trigger rate-limit handler for pro-tier at 25 RPM", async () => { // Clear the require cache before running this test. - delete require.cache[ - require.resolve("../../src/middleware/route-ratelimit") - ] - rateLimitMiddleware = require("../../src/middleware/route-ratelimit") + // delete require.cache[ + // require.resolve("../../src/middleware/route-ratelimit") + // ] + // rateLimitMiddleware = require("../../src/middleware/route-ratelimit") + rateLimitMiddleware = new RateLimits() routeRateLimit = rateLimitMiddleware.routeRateLimit req.baseUrl = "/v3" @@ -199,10 +205,11 @@ describe("#route-ratelimits & jwt-auth", () => { it("rate-limiting should still kick in at a higher RPM for pro-tier", async () => { // Clear the require cache before running this test. - delete require.cache[ - require.resolve("../../src/middleware/route-ratelimit") - ] - rateLimitMiddleware = require("../../src/middleware/route-ratelimit") + // delete require.cache[ + // require.resolve("../../src/middleware/route-ratelimit") + // ] + // rateLimitMiddleware = require("../../src/middleware/route-ratelimit") + rateLimitMiddleware = new RateLimits() routeRateLimit = rateLimitMiddleware.routeRateLimit req.baseUrl = "/v3" From dcf8455162810f9078cc80b9c5b3ad270c5b5af2 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 4 Feb 2020 17:11:19 -0800 Subject: [PATCH 03/17] Decoded JWT token --- package-lock.json | 125 +++++++++++++++++++++++++++++- package.json | 2 + src/app.js | 3 +- src/middleware/route-ratelimit.js | 45 +++++++++++ 4 files changed, 170 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9608aa0..d278d6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -731,11 +731,27 @@ "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", "dev": true }, + "@types/bn.js": { + "version": "4.11.6", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-4.11.6.tgz", + "integrity": "sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg==", + "requires": { + "@types/node": "*" + } + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/elliptic": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.12.tgz", + "integrity": "sha512-gP1KsqoouLJGH6IJa28x7PXb3cRqh83X8HCLezd2dF+XcAIMKYv53KV+9Zn6QA561E120uOqZBQ+Jy/cl+fviw==", + "requires": { + "@types/bn.js": "*" + } + }, "@types/events": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", @@ -1158,6 +1174,17 @@ "safer-buffer": "~2.1.0" } }, + "asn1.js": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.3.0.tgz", + "integrity": "sha512-WHnQJFcOrIWT1RLOkFFBQkFVvyt9BPOOrH+Dp152Zk4R993rSzXUGPmkybIcUFhHE2d/iHH+nCaOWVCDbO8fgA==", + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "assert": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.0.0.tgz", @@ -1870,6 +1897,11 @@ "ieee754": "^1.1.4" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-equals": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/buffer-equals/-/buffer-equals-1.0.4.tgz", @@ -3241,6 +3273,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ecurve": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/ecurve/-/ecurve-1.0.6.tgz", @@ -6270,6 +6310,30 @@ "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", "dev": true }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -6297,6 +6361,25 @@ "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", "dev": true }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "keccak": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/keccak/-/keccak-1.4.0.tgz", @@ -6308,6 +6391,17 @@ "safe-buffer": "^5.1.0" } }, + "key-encoder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/key-encoder/-/key-encoder-2.0.3.tgz", + "integrity": "sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg==", + "requires": { + "@types/elliptic": "^6.4.9", + "asn1.js": "^5.0.1", + "bn.js": "^4.11.8", + "elliptic": "^6.4.1" + } + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -6526,23 +6620,46 @@ "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", "dev": true }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", "dev": true }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "dev": true + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" }, "lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", - "dev": true + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, "lodash.set": { "version": "4.3.2", diff --git a/package.json b/package.json index 7ea61fb..e093f99 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "express-rate-limit": "^5.0.0", "helmet": "^3.21.2", "ioredis": "^4.14.1", + "jsonwebtoken": "^8.5.1", + "key-encoder": "^2.0.3", "level": "^6.0.0", "mkdirp": "^1.0.0", "mocha": "^7.0.1", diff --git a/src/app.js b/src/app.js index 1a972f0..778c3cc 100644 --- a/src/app.js +++ b/src/app.js @@ -86,7 +86,8 @@ app.use(`/${v3prefix}/`, auth.mw()) // Rate limit on all v3 routes // Establish and enforce rate limits. -app.use(`/${v3prefix}/`, rateLimits.routeRateLimit) +// app.use(`/${v3prefix}/`, rateLimits.routeRateLimit) +app.use(`/${v3prefix}/`, rateLimits.newRateLimit) app.use(`/${v3prefix}/` + `health-check`, healthCheckV3) app.use(`/${v3prefix}/` + `blockchain`, blockchainV3.router) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index f8f3935..71dbe31 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -21,6 +21,10 @@ const express = require("express") const RateLimit = require("express-rate-limit") const axios = require("axios") +const jwt = require("jsonwebtoken") +const KeyEncoder = require("key-encoder").default +const keyEncoder = new KeyEncoder("secp256k1") + // Set max requests per minute const maxRequests = process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) @@ -203,6 +207,47 @@ class RateLimits { return retObj } + + newRateLimit(req, res, next) { + try { + // Create a res.locals object if not passed in. + if (!req.locals) { + req.locals = { + // default values + jwtToken: "", + proLimit: false, + apiLevel: 0 + } + } + + if (req.locals.jwtToken) { + // Hexadecimal + const publicKey = + "03e6c358092a459f7da9420de770eef3e16cf3c9c54a3d3d14ac2d7f0b82af4d7d" + + const jwtOptions = { + algorithms: ["ES256"] + } + + const pemPublicKey = keyEncoder.encodePublic(publicKey, "raw", "pem") + + // Validate the JWT token. + const decoded = jwt.verify( + req.locals.jwtToken, + pemPublicKey, + jwtOptions + ) + console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`) + } else { + console.log(`No JWT token found!`) + } + } catch (err) { + console.error(`Error in route-ratelimit.js/newRateLimit(): `, err) + // throw err + } + + next() + } } module.exports = RateLimits From 88e22de3ae11bb6a2c6d17db009c2a78acbb1e5b Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 5 Feb 2020 16:33:35 -0800 Subject: [PATCH 04/17] New rate limit working with tiers --- src/middleware/route-ratelimit.js | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index 71dbe31..d950c1b 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -25,6 +25,18 @@ const jwt = require("jsonwebtoken") const KeyEncoder = require("key-encoder").default const keyEncoder = new KeyEncoder("secp256k1") +// Redis +const Redis = require("ioredis") +const redisClient = new Redis({ enableOfflineQueue: false }) + +// Rate limiter middleware lib. +const { RateLimiterRedis } = require("rate-limiter-flexible") +const rateLimitOptions = { + storeClient: redisClient, + points: 100, // Number of points + duration: 1 // Per second +} + // Set max requests per minute const maxRequests = process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) @@ -41,6 +53,8 @@ let _this class RateLimits { constructor() { _this = this + + this.rateLimiter = new RateLimiterRedis(rateLimitOptions) } async routeRateLimit(req, res, next) { @@ -208,8 +222,10 @@ class RateLimits { return retObj } - newRateLimit(req, res, next) { + async newRateLimit(req, res, next) { try { + let userId + // Create a res.locals object if not passed in. if (!req.locals) { req.locals = { @@ -238,9 +254,27 @@ class RateLimits { jwtOptions ) console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`) + + userId = decoded.id } else { console.log(`No JWT token found!`) } + + try { + // https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#authorized-and-not-authorized-users + const key = userId ? userId : req.ip + const pointsToConsume = userId ? 1 : 30 + + console.log(`User ${userId} consuming ${pointsToConsume} points.`) + + await this.rateLimiter.consume(key, pointToConsume) + } catch (err) { + // Rate limited was triggered + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Too many requests. Your limits are currently ${maxRequests} requests per minute. Increase rate limits at https://account.bchjs.cash` + }) + } } catch (err) { console.error(`Error in route-ratelimit.js/newRateLimit(): `, err) // throw err From c7f7c68fac4d85b72391b1b26069285a5d7f075c Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 6 Feb 2020 13:19:38 -0800 Subject: [PATCH 05/17] feat(resource rate-limiting): point-based consumption based on resource usage --- src/middleware/route-ratelimit.js | 134 +++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 28 deletions(-) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index d950c1b..c29c896 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -1,20 +1,3 @@ -/* - This file controls the request-per-minute (RPM) rate limits. - - It is assumed that this middleware is run AFTER the jwt-auth.js and auth.js - middleware. - - Current rate limiting rules in requests-per-minute: - - anonymous access: 3 - - free access: 10, apiLevel = 0 - - any paid tier: 100, apiLevel > 0 - - If a person signs up for full node access but not indexer access, then the - apiLevel will be 10. If they call an endpoint that uses an indexer, the apiLevel - will be downgraded to 0 on-the-fly. Indexer endpoints will effectively be - downgraded to the anonymous access tier. -*/ - "use strict" const express = require("express") @@ -57,6 +40,23 @@ class RateLimits { this.rateLimiter = new RateLimiterRedis(rateLimitOptions) } + /* + This function controls the tierd request-per-minute (RPM) rate limits. + This is an older implementation that is currently not used. + + It is assumed that this middleware is run AFTER the jwt-auth.js and auth.js + middleware. + + Current rate limiting rules in requests-per-minute: + - anonymous access: 3 + - free access: 10, apiLevel = 0 + - any paid tier: 100, apiLevel > 0 + + If a person signs up for full node access but not indexer access, then the + apiLevel will be 10. If they call an endpoint that uses an indexer, the apiLevel + will be downgraded to 0 on-the-fly. Indexer endpoints will effectively be + downgraded to the anonymous access tier. + */ async routeRateLimit(req, res, next) { // Disable rate limiting if 0 passed from RATE_LIMIT_MAX_REQUESTS if (maxRequests === 0) return next() @@ -222,9 +222,16 @@ class RateLimits { return retObj } + /* + This is the new rate limit function that uses the rate-limiter-flexible npm + library. For the moment, it only distinguishes between anonymous usage + and registered user access. anon usage allows up to 3 RPM, whereas registered + users (with JWT tokens) have 100 RPM. + */ async newRateLimit(req, res, next) { try { let userId + let decoded // Create a res.locals object if not passed in. if (!req.locals) { @@ -248,27 +255,36 @@ class RateLimits { const pemPublicKey = keyEncoder.encodePublic(publicKey, "raw", "pem") // Validate the JWT token. - const decoded = jwt.verify( - req.locals.jwtToken, - pemPublicKey, - jwtOptions - ) - console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`) + decoded = jwt.verify(req.locals.jwtToken, pemPublicKey, jwtOptions) + // console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`) userId = decoded.id } else { console.log(`No JWT token found!`) } + // Code here for the rate limiter is adapted from this example: + // https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#authorized-and-not-authorized-users try { - // https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#authorized-and-not-authorized-users - const key = userId ? userId : req.ip - const pointsToConsume = userId ? 1 : 30 + // The resource being consumed: full node, indexer, SLPDB, etc. + const resource = _this.getResource(req.url) + console.log(`resource: ${resource}`) - console.log(`User ${userId} consuming ${pointsToConsume} points.`) + let key = userId ? userId : req.ip + key = `${key}-${resource}` - await this.rateLimiter.consume(key, pointToConsume) + // const pointsToConsume = userId ? 1 : 30 + decoded.resource = resource + const pointsToConsume = _this.calcPoints(decoded) + + console.log( + `User ${userId} consuming ${pointsToConsume} point for resource ${resource}.` + ) + + await _this.rateLimiter.consume(key, pointsToConsume) } catch (err) { + console.log(`err: `, err) + // Rate limited was triggered res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 return res.json({ @@ -282,6 +298,68 @@ class RateLimits { next() } + + // Calculates the points consumed, based on the jwt information and the route + // requested. + calcPoints(jwtInfo) { + let retVal = 30 // By default, use anonymous tier. + + try { + // console.log(`jwtInfo: ${JSON.stringify(jwtInfo, null, 2)}`) + + const apiLevel = jwtInfo.apiLevel + const resource = jwtInfo.resource + + const level20Routes = ["insight", "bitcore", "blockbook"] + const level30Routes = ["slp"] + + // Only evaluate if user is using a JWT token. + if (jwtInfo.id) { + // SLP indexer routes + if (level30Routes.includes(resource)) { + if (apiLevel >= 30) retVal = 1 + else retVal = 30 + } + + // Normal indexer routes + else if (level20Routes.includes(resource)) { + if (apiLevel >= 20) retVal = 1 + else retVal = 30 + } + + // Free tier, full node only. + else { + retVal = 1 + } + } + + return retVal + } catch (err) { + console.error(`Error in route-ratelimit.js/calcPoints()`) + // throw err + retVal = 30 + } + + return retVal + } + + // This function parses the req.url property to identify what resource + // the user is requesting. + // This was created as a function so that it can be unit tested. Not sure + // what kind of variations will be seen in production. + getResource(url) { + try { + console.log(`url: ${JSON.stringify(url, null, 2)}`) + + const splitUrl = url.split("/") + const resource = splitUrl[1] + + return resource + } catch (err) { + console.error(`Error in getResource().`) + throw err + } + } } module.exports = RateLimits From 86951eccacb43bb6348800fa9839067b44700e36 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 6 Feb 2020 14:32:59 -0800 Subject: [PATCH 06/17] Disconnecting from Redis after unit tests complete --- src/middleware/route-ratelimit.js | 4 ++++ test/v3/rate-limits.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index c29c896..7554acd 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -40,6 +40,10 @@ class RateLimits { this.rateLimiter = new RateLimiterRedis(rateLimitOptions) } + closeRedis() { + redisClient.disconnect() + } + /* This function controls the tierd request-per-minute (RPM) rate limits. This is an older implementation that is currently not used. diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index 3ded94a..79f4fb1 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -52,6 +52,10 @@ describe("#route-ratelimits & jwt-auth", () => { req.query = {} }) + after(() => { + rateLimits.closeRedis() + }) + describe("#jwt-auth.js", () => { describe("#getTokenFromHeaders", () => { it(`should populate the req.locals object correctly`, () => { From 8a558db690ba8176e9fb1b7d7959b3e28ce01923 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 6 Feb 2020 14:51:09 -0800 Subject: [PATCH 07/17] unit test for getResource() --- src/middleware/route-ratelimit.js | 8 ++++---- test/v3/rate-limits.js | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index 7554acd..961ea82 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -40,6 +40,8 @@ class RateLimits { this.rateLimiter = new RateLimiterRedis(rateLimitOptions) } + // Used to disconnect from the Redis DB. + // Called by unit tests so that node.js thread doesn't live forever. closeRedis() { redisClient.disconnect() } @@ -228,9 +230,7 @@ class RateLimits { /* This is the new rate limit function that uses the rate-limiter-flexible npm - library. For the moment, it only distinguishes between anonymous usage - and registered user access. anon usage allows up to 3 RPM, whereas registered - users (with JWT tokens) have 100 RPM. + library. */ async newRateLimit(req, res, next) { try { @@ -353,7 +353,7 @@ class RateLimits { // what kind of variations will be seen in production. getResource(url) { try { - console.log(`url: ${JSON.stringify(url, null, 2)}`) + // console.log(`url: ${JSON.stringify(url, null, 2)}`) const splitUrl = url.split("/") const resource = splitUrl[1] diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index 79f4fb1..db237cb 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -240,6 +240,18 @@ describe("#route-ratelimits & jwt-auth", () => { ) }) }) + + describe("#getResource", () => { + it("should decode a blockchain request", () => { + const url = + "/blockchain/getTxOut/62a3ea958a463a372bc0caf2c374a7f60be9c624be63a0db8db78f05809df6d8/0?include_mempool=true" + + const result = rateLimits.getResource(url) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result, "blockchain") + }) + }) }) // Generates a Basic authorization header. From 4c80776754247db7f6832d7fba3eafac84fa23cb Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 00:05:40 -0800 Subject: [PATCH 08/17] unit tests for calcPoints() --- src/middleware/route-ratelimit.js | 17 ++-- test/v3/rate-limits.js | 124 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index 961ea82..9373b86 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -235,7 +235,7 @@ class RateLimits { async newRateLimit(req, res, next) { try { let userId - let decoded + let decoded = {} // Create a res.locals object if not passed in. if (!req.locals) { @@ -275,16 +275,18 @@ class RateLimits { console.log(`resource: ${resource}`) let key = userId ? userId : req.ip - key = `${key}-${resource}` // const pointsToConsume = userId ? 1 : 30 decoded.resource = resource const pointsToConsume = _this.calcPoints(decoded) console.log( - `User ${userId} consuming ${pointsToConsume} point for resource ${resource}.` + `User ${key} consuming ${pointsToConsume} point for resource ${resource}.` ) + // Update the key so that rate limits track both the user and the resource. + key = `${key}-${resource}` + await _this.rateLimiter.consume(key, pointsToConsume) } catch (err) { console.log(`err: `, err) @@ -317,18 +319,21 @@ class RateLimits { const level20Routes = ["insight", "bitcore", "blockbook"] const level30Routes = ["slp"] + // console.log(`apiLevel: ${apiLevel}`) + // Only evaluate if user is using a JWT token. if (jwtInfo.id) { // SLP indexer routes if (level30Routes.includes(resource)) { if (apiLevel >= 30) retVal = 1 - else retVal = 30 + // else if (apiLevel >= 10) retVal = 10 + else retVal = 10 } // Normal indexer routes else if (level20Routes.includes(resource)) { if (apiLevel >= 20) retVal = 1 - else retVal = 30 + else retVal = 10 } // Free tier, full node only. @@ -353,7 +358,7 @@ class RateLimits { // what kind of variations will be seen in production. getResource(url) { try { - // console.log(`url: ${JSON.stringify(url, null, 2)}`) + console.log(`url: ${JSON.stringify(url, null, 2)}`) const splitUrl = url.split("/") const resource = splitUrl[1] diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index db237cb..892d732 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -252,6 +252,130 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(result, "blockchain") }) }) + + describe("#calcPoints", () => { + it("should return 30 points for anonymous user", () => { + const result = rateLimits.calcPoints() + // console.log(`result: ${result}`) + + assert.equal(result, 30) + }) + + it("should return 1 point for free tier requesting full node access", () => { + const jwtInfo = { + apiLevel: 10, + resource: "blockchain", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + + it("should return 10 points for free tier requesting indexer access", () => { + const jwtInfo = { + apiLevel: 10, + resource: "blockbook", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 10) + }) + + it("should return 10 points for free tier requesting SLPDB access", () => { + const jwtInfo = { + apiLevel: 10, + resource: "slp", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 10) + }) + + it("should return 1 point for indexer tier requesting full node access", () => { + const jwtInfo = { + apiLevel: 20, + resource: "blockchain", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + + it("should return 1 points for indexer tier requesting indexer access", () => { + const jwtInfo = { + apiLevel: 20, + resource: "blockbook", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + + it("should return 10 points for indexer tier requesting SLPDB access", () => { + const jwtInfo = { + apiLevel: 20, + resource: "slp", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 10) + }) + + it("should return 1 point for SLP tier requesting full node access", () => { + const jwtInfo = { + apiLevel: 30, + resource: "blockchain", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + + it("should return 1 points for SLP tier requesting indexer access", () => { + const jwtInfo = { + apiLevel: 30, + resource: "blockbook", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + + it("should return 1 points for SLP tier requesting SLPDB access", () => { + const jwtInfo = { + apiLevel: 30, + resource: "slp", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + }) + + describe("#newRateLimit", () => { + it("should pass through rate-limit middleware", async () => { + req.baseUrl = "/v3" + req.path = "/control/getNetworkInfo" + req.url = req.path + req.method = "GET" + + // Call the route twice to trigger the rate handling. + await rateLimits.newRateLimit(req, res, next) + await rateLimits.newRateLimit(req, res, next) + + // next() will be called if rate-limit is not triggered + assert.equal(next.called, true) + }) + }) }) // Generates a Basic authorization header. From 97e49180e3165cee2df80567cc49317ab693f2b5 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 00:29:39 -0800 Subject: [PATCH 09/17] Need to fix slp tier as 40 --- src/middleware/route-ratelimit.js | 16 ++++- test/v3/rate-limits.js | 106 +++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index 9373b86..bdfadf5 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -37,6 +37,7 @@ class RateLimits { constructor() { _this = this + this.jwt = jwt this.rateLimiter = new RateLimiterRedis(rateLimitOptions) } @@ -259,7 +260,11 @@ class RateLimits { const pemPublicKey = keyEncoder.encodePublic(publicKey, "raw", "pem") // Validate the JWT token. - decoded = jwt.verify(req.locals.jwtToken, pemPublicKey, jwtOptions) + decoded = _this.jwt.verify( + req.locals.jwtToken, + pemPublicKey, + jwtOptions + ) // console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`) userId = decoded.id @@ -289,7 +294,7 @@ class RateLimits { await _this.rateLimiter.consume(key, pointsToConsume) } catch (err) { - console.log(`err: `, err) + // console.log(`err: `, err) // Rate limited was triggered res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 @@ -336,9 +341,14 @@ class RateLimits { else retVal = 10 } + // Full node tier + else if (apiLevel >= 10) { + retVal = 1 + } + // Free tier, full node only. else { - retVal = 1 + retVal = 10 } } diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index 892d732..889972d 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -3,6 +3,7 @@ const chai = require("chai") const assert = chai.assert const nock = require("nock") // HTTP mocking +const sinon = require("sinon") // Used for debugging. const util = require("util") @@ -13,7 +14,7 @@ const { mockReq, mockRes, mockNext } = require("./mocks/express-mocks") // Libraries under test const RateLimits = require("../../src/middleware/route-ratelimit") -const rateLimits = new RateLimits() +let rateLimits = new RateLimits() let rateLimitMiddleware = rateLimits.routeRateLimit const controlRoute = require("../../src/routes/v3/full-node/control") @@ -26,6 +27,8 @@ let originalEnvVars // Used during transition from integration to unit tests. const jwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVkYWRlM2Y1NzM5ZTZjMGZmMDM0YjlhMSIsImlhdCI6MTU3MTY3NzQ1MCwiZXhwIjoxNTc0MjY5NDUwfQ.SSz7F7ETyBB3eoNG2VKCzPOhddtB-vrtmEoj7PxicrQ` describe("#route-ratelimits & jwt-auth", () => { + let sandbox + before(() => { // Save existing environment variables. originalEnvVars = { @@ -50,6 +53,12 @@ describe("#route-ratelimits & jwt-auth", () => { req.params = {} req.body = {} req.query = {} + + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() }) after(() => { @@ -261,7 +270,7 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(result, 30) }) - it("should return 1 point for free tier requesting full node access", () => { + it("should return 10 points for free tier requesting full node access", () => { const jwtInfo = { apiLevel: 10, resource: "blockchain", @@ -269,7 +278,7 @@ describe("#route-ratelimits & jwt-auth", () => { } const result = rateLimits.calcPoints(jwtInfo) - assert.equal(result, 1) + assert.equal(result, 10) }) it("should return 10 points for free tier requesting indexer access", () => { @@ -375,6 +384,97 @@ describe("#route-ratelimits & jwt-auth", () => { // next() will be called if rate-limit is not triggered assert.equal(next.called, true) }) + + it("should trigger rate-limit handler if rate limits exceeds 5 request per minute", async () => { + req.baseUrl = "/v3" + req.path = "/control/getNetworkInfo" + req.url = req.path + req.method = "GET" + + for (let i = 0; i < 5; i++) { + next.reset() // reset the stubbed next() function. + + await rateLimits.newRateLimit(req, res, next) + //console.log(`next() called: ${next.called}`) + } + + // Note: next() will be called unless the rate-limit kicks in. + assert.equal( + next.called, + false, + `next should not be called if rate limit was triggered.` + ) + }) + + it("should NOT trigger rate-limit for free-tier at 5 RPM", async () => { + // Create a new instance of the rate limit so we start with zeroed tracking. + rateLimits = new RateLimits() + + req.baseUrl = "/v3" + req.path = "/control/getNetworkInfo" + req.url = req.path + req.method = "GET" + + req.locals.jwtToken = "some-token" + + const jwtInfo = { + apiLevel: 10, + id: "5e3a0415eb29a962da2708b4" + } + + // Mock the call to the jwt library. + sandbox.stub(rateLimits.jwt, "verify").returns(jwtInfo) + + for (let i = 0; i < 5; i++) { + next.reset() // reset the stubbed next() function. + + await rateLimits.newRateLimit(req, res, next) + //console.log(`next() called: ${next.called}`) + } + + //console.log(`req.locals after test: ${util.inspect(req.locals)}`) + + // Note: next() will be called unless the rate-limit kicks in. + assert.equal( + next.called, + true, + `next should be called if rate limit was not triggered.` + ) + }) + + it("should trigger rate-limit for free tier after 10 RPM", async () => { + // Create a new instance of the rate limit so we start with zeroed tracking. + rateLimits = new RateLimits() + + req.baseUrl = "/v3" + req.path = "/control/getNetworkInfo" + req.url = req.path + req.method = "GET" + + req.locals.jwtToken = "some-token" + + const jwtInfo = { + apiLevel: 10, + id: "5e3a0415eb29a962da2708b4" + } + + // Mock the call to the jwt library. + sandbox.stub(rateLimits.jwt, "verify").returns(jwtInfo) + + for (let i = 0; i < 12; i++) { + next.reset() // reset the stubbed next() function. + + await rateLimits.newRateLimit(req, res, next) + //console.log(`next() called: ${next.called}`) + } + + // Note: next() will be called unless the rate-limit kicks in. + assert.equal( + next.called, + false, + `next should not be called if rate limit was triggered.` + ) + }) }) }) From c70bbd46c589d6fbe1b34e9cebad593f6de422e5 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 16:20:37 -0800 Subject: [PATCH 10/17] Getting ready to refactor another tier --- src/app.js | 2 +- src/middleware/route-ratelimit.js | 2 +- test/v3/rate-limits.js | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app.js b/src/app.js index 778c3cc..82014dc 100644 --- a/src/app.js +++ b/src/app.js @@ -87,7 +87,7 @@ app.use(`/${v3prefix}/`, auth.mw()) // Rate limit on all v3 routes // Establish and enforce rate limits. // app.use(`/${v3prefix}/`, rateLimits.routeRateLimit) -app.use(`/${v3prefix}/`, rateLimits.newRateLimit) +app.use(`/${v3prefix}/`, rateLimits.rateLimitByResource) app.use(`/${v3prefix}/` + `health-check`, healthCheckV3) app.use(`/${v3prefix}/` + `blockchain`, blockchainV3.router) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index bdfadf5..ffa76a6 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -233,7 +233,7 @@ class RateLimits { This is the new rate limit function that uses the rate-limiter-flexible npm library. */ - async newRateLimit(req, res, next) { + async rateLimitByResource(req, res, next) { try { let userId let decoded = {} diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index 889972d..98e7ff9 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -370,7 +370,7 @@ describe("#route-ratelimits & jwt-auth", () => { }) }) - describe("#newRateLimit", () => { + describe("#rateLimitByResource", () => { it("should pass through rate-limit middleware", async () => { req.baseUrl = "/v3" req.path = "/control/getNetworkInfo" @@ -378,8 +378,8 @@ describe("#route-ratelimits & jwt-auth", () => { req.method = "GET" // Call the route twice to trigger the rate handling. - await rateLimits.newRateLimit(req, res, next) - await rateLimits.newRateLimit(req, res, next) + await rateLimits.rateLimitByResource(req, res, next) + await rateLimits.rateLimitByResource(req, res, next) // next() will be called if rate-limit is not triggered assert.equal(next.called, true) @@ -394,7 +394,7 @@ describe("#route-ratelimits & jwt-auth", () => { for (let i = 0; i < 5; i++) { next.reset() // reset the stubbed next() function. - await rateLimits.newRateLimit(req, res, next) + await rateLimits.rateLimitByResource(req, res, next) //console.log(`next() called: ${next.called}`) } @@ -428,7 +428,7 @@ describe("#route-ratelimits & jwt-auth", () => { for (let i = 0; i < 5; i++) { next.reset() // reset the stubbed next() function. - await rateLimits.newRateLimit(req, res, next) + await rateLimits.rateLimitByResource(req, res, next) //console.log(`next() called: ${next.called}`) } @@ -464,7 +464,7 @@ describe("#route-ratelimits & jwt-auth", () => { for (let i = 0; i < 12; i++) { next.reset() // reset the stubbed next() function. - await rateLimits.newRateLimit(req, res, next) + await rateLimits.rateLimitByResource(req, res, next) //console.log(`next() called: ${next.called}`) } From f518ad4a3e0b5957f4094530c7e2c63e4679d807 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 16:30:20 -0800 Subject: [PATCH 11/17] Refactored for four tiers --- src/middleware/route-ratelimit.js | 14 ++++----- test/v3/rate-limits.js | 47 ++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index ffa76a6..cbd47fd 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -321,28 +321,28 @@ class RateLimits { const apiLevel = jwtInfo.apiLevel const resource = jwtInfo.resource - const level20Routes = ["insight", "bitcore", "blockbook"] - const level30Routes = ["slp"] + const level30Routes = ["insight", "bitcore", "blockbook"] + const level40Routes = ["slp"] // console.log(`apiLevel: ${apiLevel}`) // Only evaluate if user is using a JWT token. if (jwtInfo.id) { // SLP indexer routes - if (level30Routes.includes(resource)) { - if (apiLevel >= 30) retVal = 1 + if (level40Routes.includes(resource)) { + if (apiLevel >= 40) retVal = 1 // else if (apiLevel >= 10) retVal = 10 else retVal = 10 } // Normal indexer routes - else if (level20Routes.includes(resource)) { - if (apiLevel >= 20) retVal = 1 + else if (level30Routes.includes(resource)) { + if (apiLevel >= 30) retVal = 1 else retVal = 10 } // Full node tier - else if (apiLevel >= 10) { + else if (apiLevel >= 20) { retVal = 1 } diff --git a/test/v3/rate-limits.js b/test/v3/rate-limits.js index 98e7ff9..fdf021a 100644 --- a/test/v3/rate-limits.js +++ b/test/v3/rate-limits.js @@ -303,7 +303,7 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(result, 10) }) - it("should return 1 point for indexer tier requesting full node access", () => { + it("should return 1 point for full node tier requesting full node access", () => { const jwtInfo = { apiLevel: 20, resource: "blockchain", @@ -314,7 +314,7 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(result, 1) }) - it("should return 1 points for indexer tier requesting indexer access", () => { + it("should return 10 points for full-node tier requesting indexer access", () => { const jwtInfo = { apiLevel: 20, resource: "blockbook", @@ -322,10 +322,10 @@ describe("#route-ratelimits & jwt-auth", () => { } const result = rateLimits.calcPoints(jwtInfo) - assert.equal(result, 1) + assert.equal(result, 10) }) - it("should return 10 points for indexer tier requesting SLPDB access", () => { + it("should return 10 points for full node tier requesting SLPDB access", () => { const jwtInfo = { apiLevel: 20, resource: "slp", @@ -336,7 +336,7 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(result, 10) }) - it("should return 1 point for SLP tier requesting full node access", () => { + it("should return 1 point for indexer tier requesting full node access", () => { const jwtInfo = { apiLevel: 30, resource: "blockchain", @@ -347,7 +347,7 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(result, 1) }) - it("should return 1 points for SLP tier requesting indexer access", () => { + it("should return 1 points for indexer tier requesting indexer access", () => { const jwtInfo = { apiLevel: 30, resource: "blockbook", @@ -358,13 +358,46 @@ describe("#route-ratelimits & jwt-auth", () => { assert.equal(result, 1) }) - it("should return 1 points for SLP tier requesting SLPDB access", () => { + it("should return 10 points for indexer tier requesting SLPDB access", () => { const jwtInfo = { apiLevel: 30, resource: "slp", id: "5e3a0415eb29a962da2708b4" } + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 10) + }) + + it("should return 1 point for SLP tier requesting full node access", () => { + const jwtInfo = { + apiLevel: 40, + resource: "blockchain", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + + it("should return 1 points for SLP tier requesting indexer access", () => { + const jwtInfo = { + apiLevel: 40, + resource: "blockbook", + id: "5e3a0415eb29a962da2708b4" + } + + const result = rateLimits.calcPoints(jwtInfo) + assert.equal(result, 1) + }) + + it("should return 1 points for SLP tier requesting SLPDB access", () => { + const jwtInfo = { + apiLevel: 40, + resource: "slp", + id: "5e3a0415eb29a962da2708b4" + } + const result = rateLimits.calcPoints(jwtInfo) assert.equal(result, 1) }) From b83bef09d8d0daef34a62dfd1a453b9a49f9e6f4 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 16:43:59 -0800 Subject: [PATCH 12/17] Added logging to new rate limiter --- src/middleware/route-ratelimit.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/middleware/route-ratelimit.js b/src/middleware/route-ratelimit.js index cbd47fd..725c9f6 100644 --- a/src/middleware/route-ratelimit.js +++ b/src/middleware/route-ratelimit.js @@ -4,6 +4,8 @@ const express = require("express") const RateLimit = require("express-rate-limit") const axios = require("axios") +const wlogger = require("../util/winston-logging") + const jwt = require("jsonwebtoken") const KeyEncoder = require("key-encoder").default const keyEncoder = new KeyEncoder("secp256k1") @@ -64,6 +66,7 @@ class RateLimits { will be downgraded to 0 on-the-fly. Indexer endpoints will effectively be downgraded to the anonymous access tier. */ + // CT 2/7/20: Older rate-limiting code that does not scale well. async routeRateLimit(req, res, next) { // Disable rate limiting if 0 passed from RATE_LIMIT_MAX_REQUESTS if (maxRequests === 0) return next() @@ -195,6 +198,7 @@ class RateLimits { // It does fine-grane analysis on the data coming from the auth servers and // uses its output to adjust rate limits on-the-fly based on the users // permission level. + // CT 2/7/20: I believe this is older code that is only used by routeRateLimit. evalUserPermissioins(req, authData) { // console.log(`authData: ${JSON.stringify(authData, null, 2)}`) @@ -269,7 +273,7 @@ class RateLimits { userId = decoded.id } else { - console.log(`No JWT token found!`) + wlogger.debug(`No JWT token found!`) } // Code here for the rate limiter is adapted from this example: @@ -277,7 +281,7 @@ class RateLimits { try { // The resource being consumed: full node, indexer, SLPDB, etc. const resource = _this.getResource(req.url) - console.log(`resource: ${resource}`) + wlogger.debug(`resource: ${resource}`) let key = userId ? userId : req.ip @@ -285,7 +289,7 @@ class RateLimits { decoded.resource = resource const pointsToConsume = _this.calcPoints(decoded) - console.log( + wlogger.info( `User ${key} consuming ${pointsToConsume} point for resource ${resource}.` ) @@ -303,7 +307,7 @@ class RateLimits { }) } } catch (err) { - console.error(`Error in route-ratelimit.js/newRateLimit(): `, err) + wlogger.error(`Error in route-ratelimit.js/newRateLimit(): `, err) // throw err } @@ -324,7 +328,7 @@ class RateLimits { const level30Routes = ["insight", "bitcore", "blockbook"] const level40Routes = ["slp"] - // console.log(`apiLevel: ${apiLevel}`) + wlogger.debug(`apiLevel: ${apiLevel}`) // Only evaluate if user is using a JWT token. if (jwtInfo.id) { @@ -354,7 +358,7 @@ class RateLimits { return retVal } catch (err) { - console.error(`Error in route-ratelimit.js/calcPoints()`) + wlogger.error(`Error in route-ratelimit.js/calcPoints()`) // throw err retVal = 30 } @@ -368,14 +372,14 @@ class RateLimits { // what kind of variations will be seen in production. getResource(url) { try { - console.log(`url: ${JSON.stringify(url, null, 2)}`) + wlogger.debug(`url: ${JSON.stringify(url, null, 2)}`) const splitUrl = url.split("/") const resource = splitUrl[1] return resource } catch (err) { - console.error(`Error in getResource().`) + wlogger.error(`Error in getResource().`) throw err } } From bae3bb9de964ff336547d9c8f157a14b28bdec6a Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 16:56:15 -0800 Subject: [PATCH 13/17] Adding redis install to travis --- .travis.yml | 3 +++ install-redis.sh | 12 ++++++++++++ 2 files changed, 15 insertions(+) create mode 100755 install-redis.sh diff --git a/.travis.yml b/.travis.yml index 551328c..c4b3256 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,9 @@ sudo: required services: - docker +before_install: + - ./install-redis.sh + script: "npm run test" after_success: diff --git a/install-redis.sh b/install-redis.sh new file mode 100755 index 0000000..f3d6caf --- /dev/null +++ b/install-redis.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Adapted from: +# https://www.digitalocean.com/community/tutorials/how-to-install-and-secure-redis-on-ubuntu-18-04 + +# Install Redis. +sudo apt install redis-server + +# Converting to systemd managed process +#echo "supervised systemd" >> /etc/redis/redis.conf + +#sudo systemctl restart redis.service From 142ad8f3f5b0e2b858ccd5370f8a12cba035aeed Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 17:01:01 -0800 Subject: [PATCH 14/17] trying redis install again --- install-redis.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install-redis.sh b/install-redis.sh index f3d6caf..b934819 100755 --- a/install-redis.sh +++ b/install-redis.sh @@ -7,6 +7,6 @@ sudo apt install redis-server # Converting to systemd managed process -#echo "supervised systemd" >> /etc/redis/redis.conf +echo "supervised systemd" >> /etc/redis/redis.conf -#sudo systemctl restart redis.service +sudo systemctl restart redis.service From 759b5a371934d58d711b0effab052024cb32f013 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 17:03:11 -0800 Subject: [PATCH 15/17] trying redis again --- install-redis.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-redis.sh b/install-redis.sh index b934819..3270ab7 100755 --- a/install-redis.sh +++ b/install-redis.sh @@ -7,6 +7,6 @@ sudo apt install redis-server # Converting to systemd managed process -echo "supervised systemd" >> /etc/redis/redis.conf +sudo echo "supervised systemd" >> /etc/redis/redis.conf sudo systemctl restart redis.service From bae5aea85b8d671e9f4bfbc4552807f4caad614f Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 17:12:18 -0800 Subject: [PATCH 16/17] trying redis on travis again --- .travis.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index c4b3256..3415e75 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,9 +9,10 @@ sudo: required services: - docker + - redis-server -before_install: - - ./install-redis.sh +#before_install: +# - ./install-redis.sh script: "npm run test" From 23ea6bd7d38e9768fb4779f6d817c45f228be165 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 7 Feb 2020 17:14:30 -0800 Subject: [PATCH 17/17] Removing redis script --- install-redis.sh | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100755 install-redis.sh diff --git a/install-redis.sh b/install-redis.sh deleted file mode 100755 index 3270ab7..0000000 --- a/install-redis.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -# Adapted from: -# https://www.digitalocean.com/community/tutorials/how-to-install-and-secure-redis-on-ubuntu-18-04 - -# Install Redis. -sudo apt install redis-server - -# Converting to systemd managed process -sudo echo "supervised systemd" >> /etc/redis/redis.conf - -sudo systemctl restart redis.service