From 8dc488939802a51e4de16aa6bd8a84aeb3490c5d Mon Sep 17 00:00:00 2001 From: Surbhi Sharma <98279679+Surbhi-sharma1@users.noreply.github.com> Date: Tue, 14 Mar 2023 18:49:41 +0530 Subject: [PATCH] feat(middleware): feat(middleware): add ratelimit middleware (#72) add ratelimit middleware. GH-71 --- .cz-config.js | 1 + README.md | 18 ++++ .../{fixtures => }/in-memory-store.ts | 0 .../README.md | 0 .../fixtures/application.ts | 7 +- .../fixtures/sequence.ts | 2 +- .../helper.ts | 1 - .../rate-limiter.acceptance.ts | 3 +- .../ratelimit-middleware-acceptance/README.md | 1 + .../fixtures/application.ts | 39 ++++++++ .../fixtures/middleware.sequence.ts | 3 + .../ratelimit-middleware-acceptance/helper.ts | 23 +++++ .../rate-limiter.acceptance.ts | 65 ++++++++++++++ .../{fixtures => }/store.provider.ts | 3 +- .../{fixtures => }/test.controller.ts | 2 +- src/component.ts | 13 ++- src/keys.ts | 11 ++- src/middleware/index.ts | 2 + src/middleware/middleware.enum.ts | 3 + src/middleware/ratelimit.middleware.ts | 88 +++++++++++++++++++ src/types.ts | 3 + 21 files changed, 276 insertions(+), 12 deletions(-) rename src/__tests__/acceptance/{fixtures => }/in-memory-store.ts (100%) rename src/__tests__/acceptance/{ => ratelimit-action-acceptance}/README.md (100%) rename src/__tests__/acceptance/{ => ratelimit-action-acceptance}/fixtures/application.ts (89%) rename src/__tests__/acceptance/{ => ratelimit-action-acceptance}/fixtures/sequence.ts (99%) rename src/__tests__/acceptance/{ => ratelimit-action-acceptance}/helper.ts (99%) rename src/__tests__/acceptance/{ => ratelimit-action-acceptance}/rate-limiter.acceptance.ts (97%) create mode 100644 src/__tests__/acceptance/ratelimit-middleware-acceptance/README.md create mode 100644 src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/application.ts create mode 100644 src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/middleware.sequence.ts create mode 100644 src/__tests__/acceptance/ratelimit-middleware-acceptance/helper.ts create mode 100644 src/__tests__/acceptance/ratelimit-middleware-acceptance/rate-limiter.acceptance.ts rename src/__tests__/acceptance/{fixtures => }/store.provider.ts (84%) rename src/__tests__/acceptance/{fixtures => }/test.controller.ts (97%) create mode 100644 src/middleware/index.ts create mode 100644 src/middleware/middleware.enum.ts create mode 100644 src/middleware/ratelimit.middleware.ts diff --git a/.cz-config.js b/.cz-config.js index 330f4e2..43e4c32 100644 --- a/.cz-config.js +++ b/.cz-config.js @@ -35,6 +35,7 @@ module.exports = { {name: 'providers'}, {name: 'repositories'}, {name: 'typings'}, + {name: 'middleware'}, ], appendBranchNameToCommitMessage: false, diff --git a/README.md b/README.md index 36acde9..f3041ed 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,23 @@ async userDetails( } ``` +## Middleware Sequence Support + +As action based sequence will be deprecated soon, we have provided support for middleware based sequences. If you are using middleware sequence you can add ratelimit to your application by enabling ratelimit action middleware. This can be done by binding the RateLimitSecurityBindings.CONFIG in application.ts : + +```ts +this.bind(RateLimitSecurityBindings.RATELIMITCONFIG).to({ + RatelimitActionMiddleware: true, +}); +``` + +this.component(RateLimiterComponent); + +``` + +This binding needs to be done before adding the RateLimiter component to your application. +Apart from this all other steps will remain the same. + ## Feedback If you've noticed a bug or have a question or have a feature request, [search the issue tracker](https://github.com/sourcefuse/loopback4-ratelimiter/issues) to see if someone else in the community has already created a ticket. @@ -222,3 +239,4 @@ Code of conduct guidelines [here](https://github.com/sourcefuse/loopback4-rateli ## License [MIT](https://github.com/sourcefuse/loopback4-ratelimiter/blob/master/LICENSE) +``` diff --git a/src/__tests__/acceptance/fixtures/in-memory-store.ts b/src/__tests__/acceptance/in-memory-store.ts similarity index 100% rename from src/__tests__/acceptance/fixtures/in-memory-store.ts rename to src/__tests__/acceptance/in-memory-store.ts diff --git a/src/__tests__/acceptance/README.md b/src/__tests__/acceptance/ratelimit-action-acceptance/README.md similarity index 100% rename from src/__tests__/acceptance/README.md rename to src/__tests__/acceptance/ratelimit-action-acceptance/README.md diff --git a/src/__tests__/acceptance/fixtures/application.ts b/src/__tests__/acceptance/ratelimit-action-acceptance/fixtures/application.ts similarity index 89% rename from src/__tests__/acceptance/fixtures/application.ts rename to src/__tests__/acceptance/ratelimit-action-acceptance/fixtures/application.ts index 92381e1..3234208 100644 --- a/src/__tests__/acceptance/fixtures/application.ts +++ b/src/__tests__/acceptance/ratelimit-action-acceptance/fixtures/application.ts @@ -5,9 +5,10 @@ import {RestApplication} from '@loopback/rest'; import {ServiceMixin} from '@loopback/service-proxy'; import path from 'path'; import {MySequence} from './sequence'; -import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../'; -import {TestController} from './test.controller'; -import {StoreProvider} from './store.provider'; +import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../..'; + +import {StoreProvider} from '../../store.provider'; +import {TestController} from '../../test.controller'; export {ApplicationConfig}; export class TestApplication extends BootMixin( ServiceMixin(RepositoryMixin(RestApplication)), diff --git a/src/__tests__/acceptance/fixtures/sequence.ts b/src/__tests__/acceptance/ratelimit-action-acceptance/fixtures/sequence.ts similarity index 99% rename from src/__tests__/acceptance/fixtures/sequence.ts rename to src/__tests__/acceptance/ratelimit-action-acceptance/fixtures/sequence.ts index dd32c64..b7a096d 100644 --- a/src/__tests__/acceptance/fixtures/sequence.ts +++ b/src/__tests__/acceptance/ratelimit-action-acceptance/fixtures/sequence.ts @@ -9,7 +9,7 @@ import { SequenceActions, SequenceHandler, } from '@loopback/rest'; -import {RateLimitAction, RateLimitSecurityBindings} from '../../../'; +import {RateLimitAction, RateLimitSecurityBindings} from '../../../..'; export class MySequence implements SequenceHandler { constructor( diff --git a/src/__tests__/acceptance/helper.ts b/src/__tests__/acceptance/ratelimit-action-acceptance/helper.ts similarity index 99% rename from src/__tests__/acceptance/helper.ts rename to src/__tests__/acceptance/ratelimit-action-acceptance/helper.ts index 66d4ed2..33c7d91 100644 --- a/src/__tests__/acceptance/helper.ts +++ b/src/__tests__/acceptance/ratelimit-action-acceptance/helper.ts @@ -4,7 +4,6 @@ import { givenHttpServerConfig, } from '@loopback/testlab'; import {TestApplication} from './fixtures/application'; - export async function setUpApplication(): Promise { const app = new TestApplication({ rest: givenHttpServerConfig(), diff --git a/src/__tests__/acceptance/rate-limiter.acceptance.ts b/src/__tests__/acceptance/ratelimit-action-acceptance/rate-limiter.acceptance.ts similarity index 97% rename from src/__tests__/acceptance/rate-limiter.acceptance.ts rename to src/__tests__/acceptance/ratelimit-action-acceptance/rate-limiter.acceptance.ts index af2ce8c..d2ccf1c 100644 --- a/src/__tests__/acceptance/rate-limiter.acceptance.ts +++ b/src/__tests__/acceptance/ratelimit-action-acceptance/rate-limiter.acceptance.ts @@ -1,8 +1,7 @@ import {Client} from '@loopback/testlab'; +import {memoryStore} from '../store.provider'; import {TestApplication} from './fixtures/application'; -import {memoryStore} from './fixtures/store.provider'; import {setUpApplication} from './helper'; - describe('Acceptance Test Cases', () => { let app: TestApplication; let client: Client; diff --git a/src/__tests__/acceptance/ratelimit-middleware-acceptance/README.md b/src/__tests__/acceptance/ratelimit-middleware-acceptance/README.md new file mode 100644 index 0000000..5bb1778 --- /dev/null +++ b/src/__tests__/acceptance/ratelimit-middleware-acceptance/README.md @@ -0,0 +1 @@ +# Acceptance tests diff --git a/src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/application.ts b/src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/application.ts new file mode 100644 index 0000000..bdc8cac --- /dev/null +++ b/src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/application.ts @@ -0,0 +1,39 @@ +import {BootMixin} from '@loopback/boot'; +import {ApplicationConfig} from '@loopback/core'; +import {RepositoryMixin} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import {ServiceMixin} from '@loopback/service-proxy'; +import path from 'path'; +import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../..'; +import {TestController} from '../../test.controller'; + +import {MySequence} from './middleware.sequence'; +import {StoreProvider} from '../../store.provider'; +export {ApplicationConfig}; +export class TestApplication extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), +) { + constructor(options: ApplicationConfig = {}) { + super(options); + + this.sequence(MySequence); + + this.static('/', path.join(__dirname, '../public')); + this.bind(RateLimitSecurityBindings.RATELIMITCONFIG).to({ + RatelimitActionMiddleware: true, + }); + this.component(RateLimiterComponent); + + this.projectRoot = __dirname; + this.controller(TestController); + this.bind(RateLimitSecurityBindings.DATASOURCEPROVIDER).toProvider( + StoreProvider, + ); + + this.bind(RateLimitSecurityBindings.CONFIG).to({ + name: 'inMemory', + max: 5, + windowMs: 2000, + }); + } +} diff --git a/src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/middleware.sequence.ts b/src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/middleware.sequence.ts new file mode 100644 index 0000000..2fe7751 --- /dev/null +++ b/src/__tests__/acceptance/ratelimit-middleware-acceptance/fixtures/middleware.sequence.ts @@ -0,0 +1,3 @@ +import {MiddlewareSequence} from '@loopback/rest'; + +export class MySequence extends MiddlewareSequence {} diff --git a/src/__tests__/acceptance/ratelimit-middleware-acceptance/helper.ts b/src/__tests__/acceptance/ratelimit-middleware-acceptance/helper.ts new file mode 100644 index 0000000..33c7d91 --- /dev/null +++ b/src/__tests__/acceptance/ratelimit-middleware-acceptance/helper.ts @@ -0,0 +1,23 @@ +import { + Client, + createRestAppClient, + givenHttpServerConfig, +} from '@loopback/testlab'; +import {TestApplication} from './fixtures/application'; +export async function setUpApplication(): Promise { + const app = new TestApplication({ + rest: givenHttpServerConfig(), + }); + + await app.boot(); + await app.start(); + + const client = createRestAppClient(app); + + return {app, client}; +} + +export interface AppWithClient { + app: TestApplication; + client: Client; +} diff --git a/src/__tests__/acceptance/ratelimit-middleware-acceptance/rate-limiter.acceptance.ts b/src/__tests__/acceptance/ratelimit-middleware-acceptance/rate-limiter.acceptance.ts new file mode 100644 index 0000000..d2ccf1c --- /dev/null +++ b/src/__tests__/acceptance/ratelimit-middleware-acceptance/rate-limiter.acceptance.ts @@ -0,0 +1,65 @@ +import {Client} from '@loopback/testlab'; +import {memoryStore} from '../store.provider'; +import {TestApplication} from './fixtures/application'; +import {setUpApplication} from './helper'; +describe('Acceptance Test Cases', () => { + let app: TestApplication; + let client: Client; + + before('setupApplication', async () => { + ({app, client} = await setUpApplication()); + }); + afterEach(async () => { + await clearStore(); + }); + + after(async () => app.stop()); + + it('should hit end point when number of requests is less than max requests allowed', async () => { + //Max request is set to 5 while binding + for (let i = 0; i < 4; i++) { + await client.get('/test').expect(200); + } + }); + + it('should hit end point when number of requests is equal to max requests allowed', async () => { + //Max request is set to 5 while binding + for (let i = 0; i < 5; i++) { + await client.get('/test').expect(200); + } + }); + + it('should give error when number of requests is greater than max requests allowed', async () => { + //Max request is set to 5 while binding + for (let i = 0; i < 5; i++) { + await client.get('/test').expect(200); + } + await client.get('/test').expect(429); + }); + + it('should overwrite the default behaviour when rate limit decorator is applied', async () => { + //Max request is set to 1 in decorator + await client.get('/testDecorator').expect(200); + await client.get('/testDecorator').expect(429); + }); + + it('should throw no error if requests more than max are sent after window resets', async () => { + //Max request is set to 5 while binding + for (let i = 0; i < 5; i++) { + await client.get('/test').expect(200); + } + setTimeout(() => { + client + .get('/test') + .expect(200) + .then( + () => {}, + () => {}, + ); + }, 2000); + }); + + async function clearStore() { + memoryStore.resetAll(); + } +}); diff --git a/src/__tests__/acceptance/fixtures/store.provider.ts b/src/__tests__/acceptance/store.provider.ts similarity index 84% rename from src/__tests__/acceptance/fixtures/store.provider.ts rename to src/__tests__/acceptance/store.provider.ts index f020b91..77cfb94 100644 --- a/src/__tests__/acceptance/fixtures/store.provider.ts +++ b/src/__tests__/acceptance/store.provider.ts @@ -1,6 +1,7 @@ import {inject, Provider, ValueOrPromise} from '@loopback/core'; import {Store} from 'express-rate-limit'; -import {RateLimitOptions, RateLimitSecurityBindings} from '../../..'; +import {RateLimitSecurityBindings} from '../../keys'; +import {RateLimitOptions} from '../../types'; import {InMemoryStore} from './in-memory-store'; export const memoryStore = new InMemoryStore(); export class StoreProvider implements Provider { diff --git a/src/__tests__/acceptance/fixtures/test.controller.ts b/src/__tests__/acceptance/test.controller.ts similarity index 97% rename from src/__tests__/acceptance/fixtures/test.controller.ts rename to src/__tests__/acceptance/test.controller.ts index 6fd381b..93b272e 100644 --- a/src/__tests__/acceptance/fixtures/test.controller.ts +++ b/src/__tests__/acceptance/test.controller.ts @@ -1,5 +1,5 @@ import {get} from '@loopback/rest'; -import {ratelimit} from '../../..'; +import {ratelimit} from '../..'; export class TestController { constructor() {} diff --git a/src/component.ts b/src/component.ts index 2487404..ca54cd2 100644 --- a/src/component.ts +++ b/src/component.ts @@ -1,13 +1,19 @@ -import {Binding, Component, ProviderMap} from '@loopback/core'; +import {Binding, Component, inject, ProviderMap} from '@loopback/core'; +import {createMiddlewareBinding} from '@loopback/rest'; import {RateLimitSecurityBindings} from './keys'; +import {RatelimitMiddlewareProvider} from './middleware'; import { RatelimitActionProvider, RateLimitMetadataProvider, RatelimitDatasourceProvider, } from './providers'; +import {RateLimitMiddlewareConfig} from './types'; export class RateLimiterComponent implements Component { - constructor() { + constructor( + @inject(RateLimitSecurityBindings.RATELIMITCONFIG, {optional: true}) + private readonly ratelimitConfig?: RateLimitMiddlewareConfig, + ) { this.providers = { [RateLimitSecurityBindings.RATELIMIT_SECURITY_ACTION.key]: RatelimitActionProvider, @@ -18,6 +24,9 @@ export class RateLimiterComponent implements Component { this.bindings.push( Binding.bind(RateLimitSecurityBindings.CONFIG.key).to(null), ); + if (this.ratelimitConfig?.RatelimitActionMiddleware) { + this.bindings.push(createMiddlewareBinding(RatelimitMiddlewareProvider)); + } } providers?: ProviderMap; diff --git a/src/keys.ts b/src/keys.ts index 2abd630..2370f3d 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,6 +1,11 @@ import {BindingKey, MetadataAccessor} from '@loopback/core'; import {Store} from 'express-rate-limit'; -import {RateLimitAction, RateLimitOptions, RateLimitMetadata} from './types'; +import { + RateLimitAction, + RateLimitOptions, + RateLimitMetadata, + RateLimitMiddlewareConfig, +} from './types'; export namespace RateLimitSecurityBindings { export const RATELIMIT_SECURITY_ACTION = BindingKey.create( @@ -18,6 +23,10 @@ export namespace RateLimitSecurityBindings { export const DATASOURCEPROVIDER = BindingKey.create( 'sf.security.ratelimit.datasourceProvider', ); + export const RATELIMITCONFIG = + BindingKey.create( + 'sf.security.rateLimitMiddleware.config', + ); } export const RATELIMIT_METADATA_ACCESSOR = MetadataAccessor.create< diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..3dd3f15 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,2 @@ +export * from './ratelimit.middleware'; +export * from './middleware.enum'; diff --git a/src/middleware/middleware.enum.ts b/src/middleware/middleware.enum.ts new file mode 100644 index 0000000..5408741 --- /dev/null +++ b/src/middleware/middleware.enum.ts @@ -0,0 +1,3 @@ +export enum RatelimitActionMiddlewareGroup { + RATELIMIT = 'ratelimitAction', +} diff --git a/src/middleware/ratelimit.middleware.ts b/src/middleware/ratelimit.middleware.ts new file mode 100644 index 0000000..ee824ff --- /dev/null +++ b/src/middleware/ratelimit.middleware.ts @@ -0,0 +1,88 @@ +// @SONAR_STOP@ +import {CoreBindings, inject, injectable, Next, Provider} from '@loopback/core'; +import {Getter} from '@loopback/repository'; +import { + Request, + Response, + RestApplication, + HttpErrors, + Middleware, + MiddlewareContext, + asMiddleware, + RestMiddlewareGroups, +} from '@loopback/rest'; +import * as RateLimit from 'express-rate-limit'; +import {RateLimitSecurityBindings} from '../keys'; +import {RateLimitMetadata, RateLimitOptions} from '../types'; +import {RatelimitActionMiddlewareGroup} from './middleware.enum'; +@injectable( + asMiddleware({ + group: RatelimitActionMiddlewareGroup.RATELIMIT, + upstreamGroups: RestMiddlewareGroups.PARSE_PARAMS, + downstreamGroups: [RestMiddlewareGroups.INVOKE_METHOD], + }), +) +export class RatelimitMiddlewareProvider implements Provider { + constructor( + @inject.getter(RateLimitSecurityBindings.DATASOURCEPROVIDER) + private readonly getDatastore: Getter, + @inject.getter(RateLimitSecurityBindings.METADATA) + private readonly getMetadata: Getter, + @inject(CoreBindings.APPLICATION_INSTANCE) + private readonly application: RestApplication, + @inject(RateLimitSecurityBindings.CONFIG, { + optional: true, + }) + private readonly config?: RateLimitOptions, + ) {} + + value() { + const middleware = async (ctx: MiddlewareContext, next: Next) => { + await this.action(ctx.request, ctx.response); + return next(); + }; + return middleware; + } + + async action(request: Request, response: Response): Promise { + const enabledByDefault = this.config?.enabledByDefault ?? true; + const metadata: RateLimitMetadata = await this.getMetadata(); + const dataStore = await this.getDatastore(); + if (metadata && !metadata.enabled) { + return Promise.resolve(); + } + + // Perform rate limiting now + const promise = new Promise((resolve, reject) => { + // First check if rate limit options available at method level + const operationMetadata = metadata ? metadata.options : {}; + + // Create options based on global config and method level config + const opts = Object.assign({}, this.config, operationMetadata); + + if (dataStore) { + opts.store = dataStore; + } + + opts.message = new HttpErrors.TooManyRequests( + opts.message?.toString() ?? 'Method rate limit reached !', + ); + + const limiter = RateLimit.default(opts); + limiter(request, response, (err: unknown) => { + if (err) { + reject(err); + } + resolve(); + }); + }); + if (enabledByDefault === true) { + await promise; + } else if (enabledByDefault === false && metadata && metadata.enabled) { + await promise; + } else { + return Promise.resolve(); + } + } +} +// @SONAR_START@ diff --git a/src/types.ts b/src/types.ts index ef255f6..ded9d2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,3 +36,6 @@ export interface RateLimitMetadata { export type Store = MemcachedStore | MongoStore | RedisStore; export type Writable = {-readonly [P in keyof T]: T[P]}; +export interface RateLimitMiddlewareConfig { + RatelimitActionMiddleware?: boolean; +}