From 2e1c5a0d61f5a438d7893b8cc3948d02978d00c8 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 7 Mar 2021 17:39:40 -0800 Subject: [PATCH] Finished initial tests, ready for live testing --- src/middleware/route-ratelimit2.js | 105 ++++++++++++---------- test/v4/rate-limit2-unit.js | 135 +++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 45 deletions(-) diff --git a/src/middleware/route-ratelimit2.js b/src/middleware/route-ratelimit2.js index d1d0477..a4d64d6 100644 --- a/src/middleware/route-ratelimit2.js +++ b/src/middleware/route-ratelimit2.js @@ -87,7 +87,7 @@ class RateLimits { // Exit if the user has already authenticated with Basic Authentication. if (req.locals.proLimit) { - console.log( + wlogger.debug( 'req.locals.proLimit = true; Using Basic Authentication instead of rate limits' ) return next() @@ -106,34 +106,10 @@ class RateLimits { // Internal API calls should pass the authentication data in through the // the usrObj in the body. if (req.body && req.body.usrObj) { - // if (req.body.usrObj.proLimit) { // 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' - // ) - // - // 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 { // Determine if user has exceeded their rate limits. Pass in the // JWT token if one exists. @@ -142,13 +118,38 @@ class RateLimits { res, req.body.usrObj.jwtToken ) + if (!hasExceededRateLimit) { + // Rate limits have not been exceeded. Processing can continue. return next() } else { + // trackRateLimits() returns the 'res' object with an error message + // and status code. return hasExceededRateLimit } } } + // + // + } else { + // Handle the normal use-case of external requests + + // Track the rate limit for this user. Pass in the JWT token, if one + // is available. + const hasExceededRateLimit = await _this.trackRateLimits( + req, + res, + req.locals.jwtToken + ) + + if (!hasExceededRateLimit) { + // Rate limits have not been exceeded. Processing can continue. + return next() + } else { + // trackRateLimits() returns the 'res' object with an error message + // and status code. + return hasExceededRateLimit + } } } catch (err) { console.error('Error in route-ratelimit2.js/applyRateLimits(): ', err) @@ -158,14 +159,14 @@ class RateLimits { next() } - // A wrapper for Redis-based rate limiter. This function is called by - // applyRateLimits(), after it's determined the key to use. + // A wrapper for Redis-based rate limiter. // 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. + // it will return the 'res' object with an error status and message, which + // 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 + let key = req.ip // Use the IP address as the key, by default. try { // Decode the JWT token if it exists @@ -173,7 +174,9 @@ class RateLimits { const decoded = _this.decodeJwtToken(jwtToken) // console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`) + // Preferentially use the decoded ID in the JWT payload, as the key. key = decoded.id + pointsToConsume = decoded.pointsToConsume } console.log(`rate limit key: ${key}`) @@ -204,25 +207,42 @@ class RateLimits { // Attempts to decode a JWT token. Returns default values if it fails. decodeJwtToken (jwtToken) { - // Default values, in case there is an error. - let decoded = { + const defaultPayload = { id: '123.456.789.10', email: 'test@bchtest.net', apiLevel: 10, rateLimit: 3, pointsToConsume: 50, - duration: 30, - iat: 1615157087, - exp: 1617749087 + duration: 30 } try { - decoded = _this.jwt.verify(jwtToken, _this.config.apiTokenSecret) - } catch (err) { - console.error('Error in route-ratelimit2.js/decodeJwtTokens()') - } + // Default values, in case there is an error. + const defaultJwt = _this.generateJwtToken(defaultPayload) - return decoded + // Generate a default payload to use, if the decoding of the user-provided + // jwt fails. + let decoded = _this.jwt.verify(defaultJwt, _this.config.apiTokenSecret) + + try { + decoded = _this.jwt.verify(jwtToken, _this.config.apiTokenSecret) + } catch (err) { + wlogger.error('Error in route-ratelimit2.js/decodeJwtTokens(): ', err) + } + + return decoded + } catch (err) { + wlogger.error( + 'Unhandled error in route-ratelimit2.js/deocdeJwtToken: ', + err + ) + + // Making sure there is an exp property. Not sure if this will cause an + // issue, using a hard-coded value. + defaultPayload.exp = 1574269450 + + return defaultPayload + } } // Returns a boolean if the origin of the request matches a domain in the @@ -301,13 +321,8 @@ class RateLimits { expiresIn: '30 days' } - const jwtPayload = { - id: payload.id, - pointsToConsume: payload.pointsToConsume - } - const token = _this.jwt.sign( - jwtPayload, + payload, _this.config.apiTokenSecret, jwtOptions ) diff --git a/test/v4/rate-limit2-unit.js b/test/v4/rate-limit2-unit.js index bfae6bf..ad1db41 100644 --- a/test/v4/rate-limit2-unit.js +++ b/test/v4/rate-limit2-unit.js @@ -121,6 +121,85 @@ describe('#rate-routelimit2', () => { }) }) + describe('#decodeJwtToken', () => { + it('should return the default JWT payload if decoding fails', () => { + const jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVlODhhY2JmMDIyMWMxMDAxMmFkOTNmZiIsImVtYWlsIjoiY2hyaXMudHJvdXRuZXJAZ21haWwuY29tIiwiYXBpTGV2ZWwiOjQwLCJyYXRlTGltaXQiOjMsImlhdCI6MTYxNTE1NzA4NywiZXhwIjoxNjE3NzQ5MDg3fQ.RLNGuYAa-CcLdhTGD27tDeaxT6-GIdeR8T4JWZZLDZA' + + const result = uut.decodeJwtToken(jwt) + // console.log('result: ', result) + + assert.property(result, 'id') + assert.equal(result.id, '123.456.789.10') + assert.property(result, 'email') + assert.equal(result.email, 'test@bchtest.net') + assert.property(result, 'pointsToConsume') + assert.equal(result.pointsToConsume, 50) + assert.property(result, 'duration') + assert.equal(result.duration, 30) + assert.property(result, 'exp') + }) + + it('should return the default JWT payload if no input is given', () => { + const result = uut.decodeJwtToken() + // console.log('result: ', result) + + assert.property(result, 'id') + assert.equal(result.id, '123.456.789.10') + assert.property(result, 'email') + assert.equal(result.email, 'test@bchtest.net') + assert.property(result, 'pointsToConsume') + assert.equal(result.pointsToConsume, 50) + assert.property(result, 'duration') + assert.equal(result.duration, 30) + assert.property(result, 'exp') + }) + + it('should correctly decode a JWT token', () => { + // Generate a new JWT token for the test. + const jwtPayload = { + id: '5dade3f5739e6c0ff034b9a1', + pointsToConsume: 10, + email: 'gooduser@test.com', + apiLevel: 40, + rateLimit: 100, + duration: 30 + } + const jwtToken = uut.generateJwtToken(jwtPayload) + + const result = uut.decodeJwtToken(jwtToken) + // console.log('result: ', result) + + assert.property(result, 'id') + assert.equal(result.id, jwtPayload.id) + assert.property(result, 'email') + assert.equal(result.email, jwtPayload.email) + assert.property(result, 'pointsToConsume') + assert.equal(result.pointsToConsume, jwtPayload.pointsToConsume) + assert.property(result, 'duration') + assert.equal(result.duration, jwtPayload.duration) + assert.property(result, 'exp') + }) + + it('should return the default payload if there is an unhandled error', () => { + // Force an error. + sandbox.stub(uut, 'generateJwtToken').throws(new Error('test error')) + + const result = uut.decodeJwtToken() + // console.log('result: ', result) + + assert.property(result, 'id') + assert.equal(result.id, '123.456.789.10') + assert.property(result, 'email') + assert.equal(result.email, 'test@bchtest.net') + assert.property(result, 'pointsToConsume') + assert.equal(result.pointsToConsume, 50) + assert.property(result, 'duration') + assert.equal(result.duration, 30) + assert.property(result, 'exp') + }) + }) + describe('#trackRateLimits', () => { it('should apply anonymous rate limits if no JWT token is provided', async () => { req.ip = '127.0.0.1' @@ -195,5 +274,61 @@ describe('#rate-routelimit2', () => { 'Expecting next to be called' ) }) + + it('should apply rate limits to anonymous users', async () => { + req.ip = '123.456.7.8' + + // 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' + ) + + assert.equal( + res.locals.pointsToConsume, + 50, + 'Anonymous rate limits applied' + ) + }) + + it('should apply 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) + + req.ip = '123.456.7.8' + req.locals.jwtToken = jwtToken + + // 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' + ) + + assert.equal( + res.locals.pointsToConsume, + 10, + 'Anonymous rate limits applied' + ) + }) }) })