Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rate limiting strategy #500

Merged
merged 3 commits into from
May 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
mike-marcacci marked this conversation as resolved.
Show resolved Hide resolved

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