mirror of
https://github.com/wassname/talk.git
synced 2026-09-12 13:01:11 +08:00
feat: generalized rate limiting for graphql (#2709)
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user