feat: generalized rate limiting for graphql (#2709)

This commit is contained in:
Wyatt Johnson
2019-11-19 14:20:36 -07:00
committed by GitHub
parent 2491445579
commit bb23c80004
10 changed files with 139 additions and 194 deletions
@@ -7,16 +7,18 @@ import {
UserSuspended,
} from "coral-server/errors";
import CommonContext from "coral-server/graph/common/context";
import {
GQLUSER_AUTH_CONDITIONS,
GQLUSER_ROLE,
} from "coral-server/graph/tenant/schema/__generated__/types";
import {
consolidateUserStatus,
consolidateUserSuspensionStatus,
User,
} from "coral-server/models/user";
import { GraphQLResolveInfo, ResponsePath } from "graphql";
import {
GQLUSER_AUTH_CONDITIONS,
GQLUSER_ROLE,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { calculateLocationKey } from "./helpers";
// Replace `memoize.Cache`.
memoize.Cache = WeakMap;
@@ -58,38 +60,6 @@ function calculateAuthConditions(
return conditions.sort();
}
/**
* calculateLocationKey will reduce the resolve information to determine the
* path to where the key that is being accessed.
*
* @param info the info from the graph request
*/
function calculateLocationKey(info: Pick<GraphQLResolveInfo, "path">): string {
// Guard against invalid input.
if (!info || !info.path || !info.path.key) {
return "";
}
// Grab the first part of the path.
const parts: string[] = [info.path.key.toString()];
// Grab the parent previous part of the path.
let prev: ResponsePath | undefined = info.path.prev;
// While there is still a previous part of the path, keep looping to find the
// all the parts.
while (prev && prev.key) {
// Push the key into the front of the array.
parts.unshift(prev.key.toString());
// Change the selection to the previous path element.
prev = prev.prev;
}
// Join it together with a dotted path.
return parts.join(".");
}
const calculateAuthConditionsMemoized = memoize(calculateAuthConditions);
const auth: DirectiveResolverFn<
@@ -0,0 +1,35 @@
import { GraphQLResolveInfo, ResponsePath } from "graphql";
/**
* calculateLocationKey will reduce the resolve information to determine the
* path to where the key that is being accessed.
*
* @param info the info from the graph request
*/
export function calculateLocationKey(
info: Pick<GraphQLResolveInfo, "path" | "operation" | "parentType">
): string {
// Guard against invalid input.
if (!info || !info.path || !info.path.key) {
return "";
}
// Grab the first part of the path.
const parts: string[] = [info.path.key.toString()];
// Grab the parent previous part of the path.
let prev: ResponsePath | undefined = info.path.prev;
// While there is still a previous part of the path, keep looping to find the
// all the parts.
while (prev && prev.key) {
// Push the key into the front of the array.
parts.unshift(prev.key.toString());
// Change the selection to the previous path element.
prev = prev.prev;
}
// Join it together with a dotted path.
return parts.join(".");
}
@@ -0,0 +1,69 @@
import { DirectiveResolverFn } from "graphql-tools";
import { DateTime } from "luxon";
import { RateLimitExceeded } from "coral-server/errors";
import { calculateLocationKey } from "coral-server/graph/common/directives/helpers";
import TenantContext from "../context";
export interface RateDirectiveArgs {
max?: number;
seconds?: number;
key?: string;
}
const rate: DirectiveResolverFn<
Record<string, string | undefined>,
TenantContext
> = async (
next,
src,
{ max = 1, seconds, key: forceResource }: RateDirectiveArgs,
{ user, tenant, now, redis, config },
info
) => {
// If we're in development mode and rate limiters are disabled, then just
// continue anyways now.
if (
config.get("env") === "development" &&
config.get("disable_rate_limiters")
) {
return next();
}
// Check if the rate limiting makes sense.
if (!seconds) {
return next();
}
// Current implementations do not handle anonymous requests.
if (!user) {
// TODO: (wyattjoh) handle anonymous requests
return next();
}
// Compute the resource key for this element.
const resource = forceResource || calculateLocationKey(info);
// TODO: (wyattjoh) depending on `resource`, maybe override (max, seconds)
// Calculate the storage key from the resource and user identifiers.
const key = `${tenant.id}:rl:${user.id}:${info.operation.operation}.${resource}`;
// Perform the rate limiting check.
const [[, tries]] = await redis
.multi()
.incr(key)
.expire(key, seconds)
.exec();
if (tries && tries > max) {
const resetsAt = DateTime.fromJSDate(now)
.plus({ seconds })
.toJSDate();
throw new RateLimitExceeded(key, max, resetsAt, tries);
}
return next();
};
export default rate;
+2 -1
View File
@@ -7,13 +7,14 @@ import {
import { loadSchema } from "coral-common/graphql";
import auth from "coral-server/graph/common/directives/auth";
import constraint from "coral-server/graph/common/directives/constraint";
import rate from "coral-server/graph/tenant/directives/rate";
import resolvers from "coral-server/graph/tenant/resolvers";
export default function getTenantSchema() {
const schema = loadSchema("tenant", resolvers as IResolvers);
// Attach the directive resolvers.
attachDirectiveResolvers(schema, { auth });
attachDirectiveResolvers(schema, { auth, rate });
// Attach the constraint directive.
SchemaDirectiveVisitor.visitSchemaDirectives(schema, {
@@ -60,6 +60,11 @@ arguments to parameters passed in to operations.
"""
directive @constraint(min: Int, max: Int) on ARGUMENT_DEFINITION
"""
rate enforces a rate limit on requests made by the user.
"""
directive @rate(max: Int = 1, seconds: Int!, key: String) on FIELD_DEFINITION
################################################################################
## Custom Scalar Types
################################################################################
@@ -5119,6 +5124,7 @@ type RequestUserCommentsDownloadPayload {
"""
archiveURL: String!
}
##################
## Mutation
##################
@@ -5127,7 +5133,9 @@ type Mutation {
"""
createComment will create a Comment as the current logged in User.
"""
createComment(input: CreateCommentInput!): CreateCommentPayload! @auth
createComment(input: CreateCommentInput!): CreateCommentPayload!
@auth
@rate(seconds: 3, key: "createComment")
"""
createCommentReply will create a Comment as the current logged in User that is
@@ -5135,7 +5143,7 @@ type Mutation {
"""
createCommentReply(
input: CreateCommentReplyInput!
): CreateCommentReplyPayload! @auth
): CreateCommentReplyPayload! @auth @rate(seconds: 3, key: "createComment")
"""
editComment will allow the author of a comment to change the body within the
@@ -5162,7 +5170,7 @@ type Mutation {
"""
createCommentReaction(
input: CreateCommentReactionInput!
): CreateCommentReactionPayload @auth
): CreateCommentReactionPayload @auth @rate(max: 2, seconds: 1)
"""
removeCommentReaction will remove a Reaction authored by the current logged in
@@ -5170,7 +5178,7 @@ type Mutation {
"""
removeCommentReaction(
input: RemoveCommentReactionInput!
): RemoveCommentReactionPayload @auth
): RemoveCommentReactionPayload @auth @rate(max: 2, seconds: 1)
"""
createCommentDontAgree will create a DontAgree authored by the current logged in
@@ -5178,7 +5186,7 @@ type Mutation {
"""
createCommentDontAgree(
input: CreateCommentDontAgreeInput!
): CreateCommentDontAgreePayload @auth
): CreateCommentDontAgreePayload @auth @rate(seconds: 3)
"""
removeCommentDontAgree will remove a DontAgree authored by the current logged in
@@ -5186,7 +5194,7 @@ type Mutation {
"""
removeCommentDontAgree(
input: RemoveCommentDontAgreeInput!
): RemoveCommentDontAgreePayload @auth
): RemoveCommentDontAgreePayload @auth @rate(seconds: 3)
"""
createCommentFlag will create a Flag authored by the current logged in User on
@@ -5194,6 +5202,7 @@ type Mutation {
"""
createCommentFlag(input: CreateCommentFlagInput!): CreateCommentFlagPayload!
@auth
@rate(seconds: 3)
"""
featureComment will mark a given Comment as featured.
@@ -5286,11 +5295,11 @@ type Mutation {
)
"""
updateUsername will set the username on the current User if they have not set one
before. This mutation will fail if the username is already set.
updateUsername will update the users username.
"""
updateUsername(input: UpdateUsernameInput!): UpdateUsernamePayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@rate(seconds: 10)
"""
setEmail will set the email address on the current User if they have not set
@@ -5311,7 +5320,9 @@ type Mutation {
updatePassword allows the current logged in User to change their password if
they already have one associated with them.
"""
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload! @auth
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload!
@auth
@rate(seconds: 10)
"""
requestAccountDeletion allows the current logged in User to request to
@@ -5319,7 +5330,7 @@ type Mutation {
"""
requestAccountDeletion(
input: RequestAccountDeletionInput!
): RequestAccountDeletionPayload! @auth
): RequestAccountDeletionPayload! @auth @rate(seconds: 10)
"""
deleteUserAccount will delete the target user now.
@@ -5359,10 +5370,11 @@ type Mutation {
): UpdateUserUsernamePayload! @auth(roles: [ADMIN])
"""
updateEmail allows administrators to update a given User's email address
to the one provided.
updateEmail will update the current users email address.
"""
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload! @auth
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload!
@auth
@rate(seconds: 10)
"""
updateNotificationSettings can be used to update the notification settings for