Skip to content

Commit

Permalink
Merge pull request #500 from the-control-group/rate_limiting
Browse files Browse the repository at this point in the history
Rate limiting strategy
  • Loading branch information
ebrown32 authored May 13, 2021
2 parents 2a1dc5a + bc54f9c commit 7a87a77
Show file tree
Hide file tree
Showing 14 changed files with 153 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/authx/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface Config {
readonly from?: string;
}) => Promise<any>;
readonly processSchema?: (schema: GraphQLSchema) => GraphQLSchema;
readonly maxRequestsPerMinute: number | null;
}

export function assertConfig(config: Config): void {
Expand Down
2 changes: 2 additions & 0 deletions packages/authx/src/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Pool } from "pg";
import { Authorization } from "./model";
import { Explanation } from "./util/explanations";
import { ReadonlyDataLoaderExecutor } from "./loader";
import { RateLimiter } from "./util/ratelimiter";

export interface Context {
readonly realm: string;
Expand All @@ -10,6 +11,7 @@ export interface Context {
readonly publicKeys: ReadonlyArray<string>;
readonly codeValidityDuration: number;
readonly jwtValidityDuration: number;
readonly rateLimiter: RateLimiter;
readonly sendMail: (options: {
readonly to: string;
readonly subject: string;
Expand Down
22 changes: 22 additions & 0 deletions packages/authx/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,25 @@ export class NotImplementedError extends Error {
this.status = this.statusCode = 501;
}
}

export class TooManyRequests extends Error {
public fileName?: string;
public lineNumber?: number;
public status: 429 = 429;
public statusCode: 429 = 429;
public expose: boolean;

public constructor(
message?: string,
expose: boolean = true,
fileName?: string,
lineNumber?: number
) {
super(message);
if (typeof fileName !== undefined) this.fileName = fileName;
if (typeof lineNumber !== undefined) this.lineNumber = lineNumber;
this.message = message || "Too many requests.";
this.expose = expose;
this.status = this.statusCode = 429;
}
}
3 changes: 3 additions & 0 deletions packages/authx/src/graphql/GraphQLAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export const GraphQLAuthorization: GraphQLObjectType<
privateKey,
authorization: a,
executor,
rateLimiter,
}: Context
): Promise<null | string> {
if (!a) return null;
Expand All @@ -174,6 +175,8 @@ export const GraphQLAuthorization: GraphQLObjectType<
}

if (args.format === "bearer") {
rateLimiter.limit(authorization.id);

const tokenId = v4();
const grant = await authorization.grant(executor);
await authorization.invoke(executor, {
Expand Down
13 changes: 13 additions & 0 deletions packages/authx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { StrategyCollection } from "./StrategyCollection";
import { UnsupportedMediaTypeError } from "./errors";
import { createAuthXExplanations } from "./explanations";
import { DataLoaderExecutor } from "./loader";
import {
LocalMemoryRateLimiter,
NoOpRateLimiter,
RateLimiter,
} from "./util/ratelimiter";

export * from "./x";
export * from "./errors";
Expand All @@ -33,6 +38,7 @@ type AuthXMiddleware = Middleware<any, KoaContext & { [x]: Context }>;

export class AuthX extends Router<any, { [x]: Context }> {
public readonly pool: Pool;
public readonly rateLimiter: RateLimiter;
public constructor(config: Config & IRouterOptions) {
assertConfig(config);
super(config);
Expand All @@ -46,6 +52,10 @@ export class AuthX extends Router<any, { [x]: Context }> {

// create a database pool
this.pool = new Pool(config.pg);
this.rateLimiter =
typeof config.maxRequestsPerMinute === "number"
? new LocalMemoryRateLimiter(config.maxRequestsPerMinute)
: new NoOpRateLimiter();

// define the context middleware
const contextMiddleware = async (
Expand All @@ -69,6 +79,8 @@ export class AuthX extends Router<any, { [x]: Context }> {
if (basic) {
authorization = await fromBasic(tx, basic);

this.rateLimiter.limit(authorization.id);

// Invoke the authorization. Because the resource validates basic
// tokens by making a GraphQL request here, each request can be
// considered an invocation.
Expand Down Expand Up @@ -105,6 +117,7 @@ export class AuthX extends Router<any, { [x]: Context }> {
authorization,
explanations: explanations,
executor: new DataLoaderExecutor(this.pool, strategies),
rateLimiter: this.rateLimiter,
};

ctx[x] = context;
Expand Down
4 changes: 4 additions & 0 deletions packages/authx/src/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ async function oAuth2Middleware(
);
}

ctx[x].rateLimiter.limit(paramsClientId);

// Authenticate the client with its secret.
let client;
try {
Expand Down Expand Up @@ -659,6 +661,8 @@ async function oAuth2Middleware(
);
}

ctx[x].rateLimiter.limit(paramsClientId);

const requestedScopeTemplates = paramsScope
? paramsScope.split(" ")
: [];
Expand Down
64 changes: 64 additions & 0 deletions packages/authx/src/util/ratelimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { performance } from "perf_hooks";
import { TooManyRequests } from "../errors";

/**
* Applies a simple in-memory rate limiting scheme. This system is designed to prevent a single
* malfunctioning client from bringing down the service. This system is only designed to prevent
* unintentional abuse by malfunctioning clients.
*/
export interface RateLimiter {
/**
* Applies the configured rate limit to the given key. If the key is being excessively used, throws a TooManyRequests exception.
* @param key A string representing the key to use for rate limiting. This is generally the id of an authorization,
* client, or credential.
*/
limit(key: string): void;
}

export class LocalMemoryRateLimiter implements RateLimiter {
private readonly map: { [key: string]: number[] } = {
__proto__: null as any,
};

constructor(
private readonly limitPerWindow = 100,
private readonly window = 60 * 1_000,
private readonly timeSource: () => number = performance.now
) {}

limit(key: string): void {
const currentTime = this.timeSource();

for (const existingKey of Object.keys(this.map)) {
for (let i = 0; i < this.map[existingKey].length; ++i) {
if (currentTime - this.map[existingKey][i] > this.window) {
this.map[existingKey].splice(i, 1);
--i;
}
}

if (this.map[existingKey].length == 0) {
delete this.map[existingKey];
}
}

if (
typeof this.map[key] !== "undefined" &&
this.map[key].length >= this.limitPerWindow
) {
throw new TooManyRequests();
}

if (typeof this.map[key] === "undefined") {
this.map[key] = [];
}

this.map[key].push(currentTime);
}
}

export class NoOpRateLimiter implements RateLimiter {
limit(): void {
// This rate limiter does nothing. It only exists so that clients can call some rate limiter.
}
}
32 changes: 32 additions & 0 deletions packages/authx/src/util/raterlimiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import test from "ava";
import { LocalMemoryRateLimiter } from "./ratelimiter";

test("Rate limiter over rate", async (t) => {
let curTime = 0;

const limiter = new LocalMemoryRateLimiter(3, 60 * 1_000, () => {
curTime += 14_000;
return curTime;
});

limiter.limit("A");
limiter.limit("B");
limiter.limit("C");
limiter.limit("A");
limiter.limit("B");
limiter.limit("C");
limiter.limit("A");
limiter.limit("B");
limiter.limit("C");

limiter.limit("A");
limiter.limit("A");
limiter.limit("A");

try {
limiter.limit("A");
t.fail("4th call in the same minute should cause 429");
} catch (ex) {
t.pass();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ export const authenticateEmail: GraphQLFieldConfig<
);
}

context.rateLimiter.limit(credential.id);

// Invoke the credential.
await credential.invoke(executor, {
id: v4(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ export const authenticateOpenId: GraphQLFieldConfig<
throw new AuthenticationError("No such credential exists.");
}

context.rateLimiter.limit(credential.id);

// Invoke the credential.
await credential.invoke(executor, {
id: v4(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export const authenticatePassword: GraphQLFieldConfig<
);
}

context.rateLimiter.limit(credential.id);

// Invoke the credential.
await credential.invoke(executor, {
id: v4(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ export const authenticateSaml: GraphQLFieldConfig<
);
}

context.rateLimiter.limit(credential.id);

// Invoke the credential.
await credential.invoke(executor, {
id: v4(),
Expand Down
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ Bac/x5qiUn5fh2xM+wIDAQAB
ssl: process.env.PGSSL === "true" ? true : false,
user: process.env.PGUSER ?? undefined,
},
maxRequestsPerMinute: process.env.MAX_REQUESTS_PER_KEY_PER_MINUTE
? parseFloat(process.env.MAX_REQUESTS_PER_KEY_PER_MINUTE)
: null,
});

// Apply the AuthX routes to the app.
Expand Down
1 change: 1 addition & 0 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ Bac/x5qiUn5fh2xM+wIDAQAB
ssl: process.env.PGSSL === "true" ? true : false,
user: process.env.PGUSER ?? undefined,
},
maxRequestsPerMinute: null,
});

// Apply the AuthX routes to the app.
Expand Down

0 comments on commit 7a87a77

Please sign in to comment.