diff --git a/src/middleware/route-ratelimit2.js b/src/middleware/route-ratelimit2.js index 3bb9527..d1d0477 100644 --- a/src/middleware/route-ratelimit2.js +++ b/src/middleware/route-ratelimit2.js @@ -87,7 +87,9 @@ class RateLimits { // Exit if the user has already authenticated with Basic Authentication. if (req.locals.proLimit) { - console.log('req.locals.proLimit = true; Using Basic Authentication instead of rate limits') + console.log( + 'req.locals.proLimit = true; Using Basic Authentication instead of rate limits' + ) return next() } @@ -109,16 +111,42 @@ class RateLimits { // If this is an internal call that originated from a user using // Basic Authentication, then skip rate-limits. return next() + // - } else if (req.body.usrObj.jwtToken) { - // Internal call originated from a user using a JWT token. - console.log( - 'Internal call originated from a user using a JWT token' - ) + // } else if (req.body.usrObj.jwtToken) { + // // Internal call originated from a user using a JWT token. + // console.log( + // 'Internal call originated from a user using a JWT token' + // ) // + // const hasExceededRateLimit = await _this.trackRateLimits( + // req, + // res, + // req.body.usrObj.jwtToken + // ) + // if (!hasExceededRateLimit) { + // return next() + // } else { + // return hasExceededRateLimit + // } + // // + // } else { + // // Internal call originates from an anonymous user. + // console.log('Internal call originates from an anonymous user') + // } } else { - // Internal call originates from an anonymous user. - console.log('Internal call originates from an anonymous user') + // Determine if user has exceeded their rate limits. Pass in the + // JWT token if one exists. + const hasExceededRateLimit = await _this.trackRateLimits( + req, + res, + req.body.usrObj.jwtToken + ) + if (!hasExceededRateLimit) { + return next() + } else { + return hasExceededRateLimit + } } } } @@ -126,9 +154,77 @@ class RateLimits { console.error('Error in route-ratelimit2.js/applyRateLimits(): ', err) } + // By default, move to the next middleware. next() } + // A wrapper for Redis-based rate limiter. This function is called by + // applyRateLimits(), after it's determined the key to use. + // Will return false if the user has not exceeded the rate limit. Otherwise + // it will return the res object that should be returned by the middleware. + async trackRateLimits (req, res, jwtToken) { + // Anonymous rate limits are used by default. + let pointsToConsume = 50 + let key = req.ip + + try { + // Decode the JWT token if it exists + if (jwtToken) { + const decoded = _this.decodeJwtToken(jwtToken) + // console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`) + + key = decoded.id + pointsToConsume = decoded.pointsToConsume + } + console.log(`rate limit key: ${key}`) + + // This function will throw an error if the user exceeds the rate limit. + // The 429 error response is handled by the catch(). + await _this.rateLimiter.consume(key, pointsToConsume) + + res.locals.pointsToConsume = pointsToConsume // Feedback for tests. + + // Signal that the user has not exceeded their rate limits. + return false + } catch (err) { + console.log('err: ', err) + + const rateLimit = Math.floor(1000 / pointsToConsume) + + res.locals.rateLimitTriggered = true + // console.log('res.locals: ', res.locals) + + // 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 ${rateLimit} requests per minute. Increase rate limits at https://fullstack.cash` + }) + } + } + + // Attempts to decode a JWT token. Returns default values if it fails. + decodeJwtToken (jwtToken) { + // Default values, in case there is an error. + let decoded = { + id: '123.456.789.10', + email: 'test@bchtest.net', + apiLevel: 10, + rateLimit: 3, + pointsToConsume: 50, + duration: 30, + iat: 1615157087, + exp: 1617749087 + } + + try { + decoded = _this.jwt.verify(jwtToken, _this.config.apiTokenSecret) + } catch (err) { + console.error('Error in route-ratelimit2.js/decodeJwtTokens()') + } + + return decoded + } + // Returns a boolean if the origin of the request matches a domain in the // whitelist. isInWhitelist (req) { @@ -139,15 +235,14 @@ class RateLimits { const origin = req.get('origin') console.log(`origin: ${origin}`) + // If the origin is not determinable, return false. + if (!origin) return false + // console.log(`WHITELIST_DOMAINS: ${JSON.stringify(WHITELIST_DOMAINS, null, 2)}`) for (let i = 0; i < WHITELIST_DOMAINS.length; i++) { const thisDomain = WHITELIST_DOMAINS[i] - // if (origin.toString().indexOf(thisDomain) > -1) { - // return true - // } - if (origin.includes(thisDomain)) return true } @@ -196,6 +291,33 @@ class RateLimits { async wipeRedis () { await redisClient.flushdb() } + + // Generates a JWT token for testing purposes. This is not used in production. + // This function mirrors the kind of JWT token that would be generated by + // jwt-bch-api. + generateJwtToken (payload) { + try { + const jwtOptions = { + expiresIn: '30 days' + } + + const jwtPayload = { + id: payload.id, + pointsToConsume: payload.pointsToConsume + } + + const token = _this.jwt.sign( + jwtPayload, + _this.config.apiTokenSecret, + jwtOptions + ) + + return token + } catch (err) { + console.error('Error in generateJwtToken()') + throw err + } + } } module.exports = RateLimits diff --git a/test/v4/rate-limit2-unit.js b/test/v4/rate-limit2-unit.js index 2802bad..bfae6bf 100644 --- a/test/v4/rate-limit2-unit.js +++ b/test/v4/rate-limit2-unit.js @@ -100,6 +100,7 @@ describe('#rate-routelimit2', () => { it('should return false when origin is not in the whitelist', () => { req.origin = 'blah.com' + req.get = sandbox.stub().returns(req.origin) const result = uut.isInWhitelist(req) @@ -110,12 +111,89 @@ describe('#rate-routelimit2', () => { next() }) - // it('should return true when origin is in the whitelist', () => { - // req.origin = 'message.fullstack.cash' - // - // const result = uut.isInWhitelist(req) - // - // assert.equal(result, true) - // }) + it('should return true when origin is in the whitelist', () => { + req.origin = 'message.fullstack.cash' + req.get = sandbox.stub().returns(req.origin) + + const result = uut.isInWhitelist(req) + + assert.equal(result, true) + }) + }) + + describe('#trackRateLimits', () => { + it('should apply anonymous rate limits if no JWT token is provided', async () => { + req.ip = '127.0.0.1' + + const result = await uut.trackRateLimits(req, res) + // console.log(`result: `, result) + + // console.log('res.locals.pointsToConsume: ', res.locals.pointsToConsume) + + assert.equal(result, false, 'Rate limits not exceeded') + assert.equal( + res.locals.pointsToConsume, + 50, + 'Anonymous rate limits applied' + ) + }) + + it('should apply 100 RPM rate limits when JWT token is provided', async () => { + // Generate a new JWT token for the test. + const jwtPayload = { + id: '5dade3f5739e6c0ff034b9a1', + pointsToConsume: 10 + } + const jwtToken = uut.generateJwtToken(jwtPayload) + + const result = await uut.trackRateLimits(req, res, jwtToken) + // console.log(`result: `, result) + + // console.log('res.locals.pointsToConsume: ', res.locals.pointsToConsume) + + assert.equal(result, false, 'Rate limits not exceeded') + assert.equal(res.locals.pointsToConsume, 10, '100 RPM limits applied') + }) + }) + + describe('#applyRateLimits', () => { + it('should skip rate limits if basic auth token is used', async () => { + req.locals.proLimit = true + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next to be called' + ) + }) + + it('should skip rate limits if internal call passes basic auth token', async () => { + req.ip = '127.0.0.1' + req.body.usrObj = { + proLimit: true + } + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next to be called' + ) + }) }) })