feat: rate limiter (#4011)

This commit is contained in:
DarkSky 2023-08-31 20:29:25 +08:00 committed by GitHub
parent 8e48255ef8
commit 4ef1425299
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 184 additions and 12 deletions

View File

@ -12,7 +12,9 @@
"start": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/index.ts", "start": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/index.ts",
"dev": "nodemon ./src/index.ts", "dev": "nodemon ./src/index.ts",
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all", "test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
"test:select": "yarn exec ts-node-esm ./scripts/run-test.ts",
"test:watch": "yarn exec ts-node-esm ./scripts/run-test.ts all --watch", "test:watch": "yarn exec ts-node-esm ./scripts/run-test.ts all --watch",
"test:select:watch": "yarn exec ts-node-esm ./scripts/run-test.ts --watch",
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all", "test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
@ -28,6 +30,7 @@
"@nestjs/graphql": "^12.0.8", "@nestjs/graphql": "^12.0.8",
"@nestjs/platform-express": "^10.2.2", "@nestjs/platform-express": "^10.2.2",
"@nestjs/platform-socket.io": "^10.2.2", "@nestjs/platform-socket.io": "^10.2.2",
"@nestjs/throttler": "^4.2.1",
"@nestjs/websockets": "^10.2.2", "@nestjs/websockets": "^10.2.2",
"@node-rs/argon2": "^1.5.2", "@node-rs/argon2": "^1.5.2",
"@node-rs/crc32": "^1.7.2", "@node-rs/crc32": "^1.7.2",
@ -55,6 +58,7 @@
"graphql-upload": "^16.0.2", "graphql-upload": "^16.0.2",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"nestjs-throttler-storage-redis": "^0.3.3",
"next-auth": "4.22.5", "next-auth": "4.22.5",
"nodemailer": "^6.9.4", "nodemailer": "^6.9.4",
"on-headers": "^1.0.2", "on-headers": "^1.0.2",

View File

@ -6,6 +6,7 @@ import { MetricsModule } from './metrics';
import { BusinessModules } from './modules'; import { BusinessModules } from './modules';
import { PrismaModule } from './prisma'; import { PrismaModule } from './prisma';
import { StorageModule } from './storage'; import { StorageModule } from './storage';
import { RateLimiterModule } from './throttler';
@Module({ @Module({
imports: [ imports: [
@ -13,6 +14,7 @@ import { StorageModule } from './storage';
ConfigModule.forRoot(), ConfigModule.forRoot(),
StorageModule.forRoot(), StorageModule.forRoot(),
MetricsModule, MetricsModule,
RateLimiterModule,
...BusinessModules, ...BusinessModules,
], ],
controllers: [AppController], controllers: [AppController],

View File

@ -187,6 +187,25 @@ export interface AFFiNEConfig {
path: string; path: string;
}; };
}; };
/**
* Rate limiter config
*/
rateLimiter: {
/**
* How long each request will be throttled (seconds)
* @default 60
* @env THROTTLE_TTL
*/
ttl: number;
/**
* How many requests can be made in the given time frame
* @default 60
* @env THROTTLE_LIMIT
*/
limit: number;
};
/** /**
* Redis Config * Redis Config
* *

View File

@ -72,6 +72,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
OAUTH_EMAIL_SERVER: 'auth.email.server', OAUTH_EMAIL_SERVER: 'auth.email.server',
OAUTH_EMAIL_PORT: ['auth.email.port', 'int'], OAUTH_EMAIL_PORT: ['auth.email.port', 'int'],
OAUTH_EMAIL_PASSWORD: 'auth.email.password', OAUTH_EMAIL_PASSWORD: 'auth.email.password',
THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
REDIS_SERVER_ENABLED: ['redis.enabled', 'boolean'], REDIS_SERVER_ENABLED: ['redis.enabled', 'boolean'],
REDIS_SERVER_HOST: 'redis.host', REDIS_SERVER_HOST: 'redis.host',
REDIS_SERVER_PORT: ['redis.port', 'int'], REDIS_SERVER_PORT: ['redis.port', 'int'],
@ -169,6 +171,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
path: join(homedir(), '.affine-storage'), path: join(homedir(), '.affine-storage'),
}, },
}, },
rateLimiter: {
ttl: 60,
limit: 60,
},
redis: { redis: {
enabled: false, enabled: false,
host: '127.0.0.1', host: '127.0.0.1',

View File

@ -42,8 +42,6 @@ export class MailService {
}; };
} }
) { ) {
console.log('invitationInfo', invitationInfo);
const buttonUrl = `${this.config.baseUrl}/invite/${inviteId}`; const buttonUrl = `${this.config.baseUrl}/invite/${inviteId}`;
const workspaceAvatar = invitationInfo.workspace.avatar; const workspaceAvatar = invitationInfo.workspace.avatar;

View File

@ -9,6 +9,7 @@ import {
Query, Query,
Req, Req,
Res, Res,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { hash, verify } from '@node-rs/argon2'; import { hash, verify } from '@node-rs/argon2';
import type { User } from '@prisma/client'; import type { User } from '@prisma/client';
@ -19,6 +20,7 @@ import { AuthHandler } from 'next-auth/core';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma/service'; import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { NextAuthOptionsProvide } from './next-auth-options'; import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service'; import { AuthService } from './service';
@ -41,6 +43,8 @@ export class NextAuthController {
this.callbackSession = nextAuthOptions.callbacks!.session; this.callbackSession = nextAuthOptions.callbacks!.session;
} }
@UseGuards(CloudThrottlerGuard)
@Throttle(20, 60)
@All('*') @All('*')
async auth( async auth(
@Req() req: Request, @Req() req: Request,

View File

@ -1,4 +1,4 @@
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException, UseGuards } from '@nestjs/common';
import { import {
Args, Args,
Context, Context,
@ -12,6 +12,7 @@ import {
import type { Request } from 'express'; import type { Request } from 'express';
import { Config } from '../../config'; import { Config } from '../../config';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { UserType } from '../users/resolver'; import { UserType } from '../users/resolver';
import { CurrentUser } from './guard'; import { CurrentUser } from './guard';
import { AuthService } from './service'; import { AuthService } from './service';
@ -25,6 +26,13 @@ export class TokenType {
refresh!: string; refresh!: string;
} }
/**
* Auth resolver
* Token rate limit: 20 req/m
* Sign up/in rate limit: 10 req/m
* Other rate limit: 5 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Resolver(() => UserType) @Resolver(() => UserType)
export class AuthResolver { export class AuthResolver {
constructor( constructor(
@ -32,6 +40,7 @@ export class AuthResolver {
private auth: AuthService private auth: AuthService
) {} ) {}
@Throttle(20, 60)
@ResolveField(() => TokenType) @ResolveField(() => TokenType)
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) { token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
if (user.id !== currentUser.id) { if (user.id !== currentUser.id) {
@ -44,6 +53,7 @@ export class AuthResolver {
}; };
} }
@Throttle(10, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
async signUp( async signUp(
@Context() ctx: { req: Request }, @Context() ctx: { req: Request },
@ -56,6 +66,7 @@ export class AuthResolver {
return user; return user;
} }
@Throttle(10, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
async signIn( async signIn(
@Context() ctx: { req: Request }, @Context() ctx: { req: Request },
@ -67,6 +78,7 @@ export class AuthResolver {
return user; return user;
} }
@Throttle(5, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
async changePassword( async changePassword(
@Context() ctx: { req: Request }, @Context() ctx: { req: Request },
@ -78,6 +90,7 @@ export class AuthResolver {
return user; return user;
} }
@Throttle(5, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
async changeEmail( async changeEmail(
@Context() ctx: { req: Request }, @Context() ctx: { req: Request },
@ -89,6 +102,7 @@ export class AuthResolver {
return user; return user;
} }
@Throttle(5, 60)
@Mutation(() => Boolean) @Mutation(() => Boolean)
async sendChangePasswordEmail( async sendChangePasswordEmail(
@Args('email') email: string, @Args('email') email: string,
@ -99,6 +113,7 @@ export class AuthResolver {
return !res.rejected.length; return !res.rejected.length;
} }
@Throttle(5, 60)
@Mutation(() => Boolean) @Mutation(() => Boolean)
async sendSetPasswordEmail( async sendSetPasswordEmail(
@Args('email') email: string, @Args('email') email: string,
@ -109,6 +124,7 @@ export class AuthResolver {
return !res.rejected.length; return !res.rejected.length;
} }
@Throttle(5, 60)
@Mutation(() => Boolean) @Mutation(() => Boolean)
async sendChangeEmail( async sendChangeEmail(
@Args('email') email: string, @Args('email') email: string,

View File

@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
HttpException, HttpException,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
Args, Args,
@ -19,6 +20,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma/service'; import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types'; import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public } from '../auth/guard'; import { Auth, CurrentUser, Public } from '../auth/guard';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
@ -69,6 +71,11 @@ export class AddToNewFeaturesWaitingList {
type!: NewFeaturesKind; type!: NewFeaturesKind;
} }
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth() @Auth()
@Resolver(() => UserType) @Resolver(() => UserType)
export class UserResolver { export class UserResolver {
@ -78,6 +85,7 @@ export class UserResolver {
private readonly config: Config private readonly config: Config
) {} ) {}
@Throttle(10, 60)
@Query(() => UserType, { @Query(() => UserType, {
name: 'currentUser', name: 'currentUser',
description: 'Get current user', description: 'Get current user',
@ -100,6 +108,7 @@ export class UserResolver {
}; };
} }
@Throttle(10, 60)
@Query(() => UserType, { @Query(() => UserType, {
name: 'user', name: 'user',
description: 'Get user by email', description: 'Get user by email',
@ -135,6 +144,7 @@ export class UserResolver {
return user; return user;
} }
@Throttle(10, 60)
@Mutation(() => UserType, { @Mutation(() => UserType, {
name: 'uploadAvatar', name: 'uploadAvatar',
description: 'Upload user avatar', description: 'Upload user avatar',
@ -155,6 +165,7 @@ export class UserResolver {
}); });
} }
@Throttle(10, 60)
@Mutation(() => DeleteAccount) @Mutation(() => DeleteAccount)
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> { async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
await this.prisma.user.delete({ await this.prisma.user.delete({
@ -172,6 +183,7 @@ export class UserResolver {
}; };
} }
@Throttle(10, 60)
@Mutation(() => AddToNewFeaturesWaitingList) @Mutation(() => AddToNewFeaturesWaitingList)
async addToNewFeaturesWaitingList( async addToNewFeaturesWaitingList(
@CurrentUser() user: UserType, @CurrentUser() user: UserType,

View File

@ -1,5 +1,10 @@
import type { Storage } from '@affine/storage'; import type { Storage } from '@affine/storage';
import { ForbiddenException, Inject, NotFoundException } from '@nestjs/common'; import {
ForbiddenException,
Inject,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { import {
Args, Args,
Field, Field,
@ -24,6 +29,7 @@ import { applyUpdate, Doc } from 'yjs';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage'; import { StorageProvide } from '../../storage';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types'; import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public } from '../auth'; import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer'; import { MailService } from '../auth/mailer';
@ -113,6 +119,12 @@ export class UpdateWorkspaceInput extends PickType(
id!: string; id!: string;
} }
/**
* Workspace resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth() @Auth()
@Resolver(() => WorkspaceType) @Resolver(() => WorkspaceType)
export class WorkspaceResolver { export class WorkspaceResolver {
@ -258,10 +270,11 @@ export class WorkspaceResolver {
}); });
} }
@Throttle(10, 30)
@Public()
@Query(() => WorkspaceType, { @Query(() => WorkspaceType, {
description: 'Get public workspace by id', description: 'Get public workspace by id',
}) })
@Public()
async publicWorkspace(@Args('id') id: string) { async publicWorkspace(@Args('id') id: string) {
const workspace = await this.prisma.workspace.findUnique({ const workspace = await this.prisma.workspace.findUnique({
where: { id }, where: { id },
@ -463,6 +476,7 @@ export class WorkspaceResolver {
} }
} }
@Throttle(10, 30)
@Public() @Public()
@Query(() => InvitationType, { @Query(() => InvitationType, {
description: 'Update workspace', description: 'Update workspace',

View File

@ -11,6 +11,7 @@ import { MetricsModule } from '../metrics';
import { AuthModule } from '../modules/auth'; import { AuthModule } from '../modules/auth';
import { AuthService } from '../modules/auth/service'; import { AuthService } from '../modules/auth/service';
import { PrismaModule } from '../prisma'; import { PrismaModule } from '../prisma';
import { RateLimiterModule } from '../throttler';
let auth: AuthService; let auth: AuthService;
let module: TestingModule; let module: TestingModule;
@ -36,6 +37,7 @@ beforeEach(async () => {
GqlModule, GqlModule,
AuthModule, AuthModule,
MetricsModule, MetricsModule,
RateLimiterModule,
], ],
}).compile(); }).compile();
auth = module.get(AuthService); auth = module.get(AuthService);

View File

@ -1,4 +1,4 @@
import { ok } from 'node:assert'; import { ok, rejects } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test'; import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common'; import type { INestApplication } from '@nestjs/common';
@ -71,7 +71,6 @@ describe('User Module', () => {
`, `,
}) })
.expect(200); .expect(200);
const current = await currentUser(app, user.token.token); rejects(currentUser(app, user.token.token));
ok(current == null);
}); });
}); });

View File

@ -43,13 +43,14 @@ async function currentUser(app: INestApplication, token: string) {
query: ` query: `
query { query {
currentUser { currentUser {
id, name, email, emailVerified, avatarUrl, createdAt, hasPassword id, name, email, emailVerified, avatarUrl, createdAt, hasPassword,
token { token }
} }
} }
`, `,
}) })
.expect(200); .expect(200);
return res.body?.data?.currentUser; return res.body.data.currentUser;
} }
async function createWorkspace( async function createWorkspace(
@ -440,7 +441,7 @@ async function getInviteInfo(
`, `,
}) })
.expect(200); .expect(200);
return res.body.data.workspace; return res.body.data.getInviteInfo;
} }
export { export {

View File

@ -12,6 +12,7 @@ import { AppModule } from '../app';
import { import {
acceptInvite, acceptInvite,
createWorkspace, createWorkspace,
currentUser,
getPublicWorkspace, getPublicWorkspace,
getWorkspaceSharedPages, getWorkspaceSharedPages,
inviteUser, inviteUser,
@ -61,6 +62,18 @@ describe('Workspace Module', () => {
ok(user.email === 'u1@affine.pro', 'user.email is not valid'); ok(user.email === 'u1@affine.pro', 'user.email is not valid');
}); });
it('should be throttled at call signUp', async () => {
let token = '';
for (let i = 0; i < 10; i++) {
token = (await signUp(app, `u${i}`, `u${i}@affine.pro`, `${i}`)).token
.token;
// throttles are applied to each endpoint separately
await currentUser(app, token);
}
await rejects(signUp(app, 'u11', 'u11@affine.pro', '11'));
await rejects(currentUser(app, token));
});
it('should create a workspace', async () => { it('should create a workspace', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '1'); const user = await signUp(app, 'u1', 'u1@affine.pro', '1');

View File

@ -0,0 +1,54 @@
import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import {
Throttle,
ThrottlerGuard,
ThrottlerModule,
ThrottlerModuleOptions,
} from '@nestjs/throttler';
import Redis from 'ioredis';
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
import { Config, ConfigModule } from './config';
import { getRequestResponseFromContext } from './utils/nestjs';
@Global()
@Module({
imports: [
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [Config],
useFactory: (config: Config): ThrottlerModuleOptions => {
const options: ThrottlerModuleOptions = {
ttl: config.rateLimiter.ttl,
limit: config.rateLimiter.limit,
};
if (config.redis.enabled) {
new Logger(RateLimiterModule.name).log('Use Redis');
options.storage = new ThrottlerStorageRedisService(
new Redis(config.redis.port, config.redis.host, {
username: config.redis.username,
password: config.redis.password,
db: config.redis.database + 1,
})
);
}
return options;
},
}),
],
})
export class RateLimiterModule {}
@Injectable()
export class CloudThrottlerGuard extends ThrottlerGuard {
override getRequestResponse(context: ExecutionContext) {
return getRequestResponseFromContext(context) as any;
}
protected override getTracker(req: Record<string, any>): string {
return req?.get('CF-Connecting-IP') ?? req?.get('CF-ray') ?? req?.ip;
}
}
export { Throttle };

View File

@ -669,6 +669,7 @@ __metadata:
"@nestjs/platform-express": ^10.2.2 "@nestjs/platform-express": ^10.2.2
"@nestjs/platform-socket.io": ^10.2.2 "@nestjs/platform-socket.io": ^10.2.2
"@nestjs/testing": ^10.2.2 "@nestjs/testing": ^10.2.2
"@nestjs/throttler": ^4.2.1
"@nestjs/websockets": ^10.2.2 "@nestjs/websockets": ^10.2.2
"@node-rs/argon2": ^1.5.2 "@node-rs/argon2": ^1.5.2
"@node-rs/crc32": ^1.7.2 "@node-rs/crc32": ^1.7.2
@ -708,6 +709,7 @@ __metadata:
graphql-upload: ^16.0.2 graphql-upload: ^16.0.2
ioredis: ^5.3.2 ioredis: ^5.3.2
lodash-es: ^4.17.21 lodash-es: ^4.17.21
nestjs-throttler-storage-redis: ^0.3.3
next-auth: 4.22.5 next-auth: 4.22.5
nodemailer: ^6.9.4 nodemailer: ^6.9.4
nodemon: ^3.0.1 nodemon: ^3.0.1
@ -7297,6 +7299,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@nestjs/throttler@npm:^4.2.1":
version: 4.2.1
resolution: "@nestjs/throttler@npm:4.2.1"
dependencies:
md5: ^2.2.1
peerDependencies:
"@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
"@nestjs/core": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
reflect-metadata: ^0.1.13
checksum: 28b1b1b3ff0623e67ecd51f237bb4a8c22c39134baa5556918a83771c9ebae9b095d0953f3e0e6ba46cc5f7279e4c9cd5c357cbdc00eccffe0e295dc180bcd76
languageName: node
linkType: hard
"@nestjs/websockets@npm:^10.2.2": "@nestjs/websockets@npm:^10.2.2":
version: 10.2.2 version: 10.2.2
resolution: "@nestjs/websockets@npm:10.2.2" resolution: "@nestjs/websockets@npm:10.2.2"
@ -25723,7 +25738,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"md5@npm:^2.3.0": "md5@npm:^2.2.1, md5@npm:^2.3.0":
version: 2.3.0 version: 2.3.0
resolution: "md5@npm:2.3.0" resolution: "md5@npm:2.3.0"
dependencies: dependencies:
@ -26522,6 +26537,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"nestjs-throttler-storage-redis@npm:^0.3.3":
version: 0.3.3
resolution: "nestjs-throttler-storage-redis@npm:0.3.3"
peerDependencies:
"@nestjs/common": ">=9.4.1"
"@nestjs/core": ">=9.4.1"
"@nestjs/throttler": ">=4.0.0"
ioredis: ^5.3.2
reflect-metadata: ^0.1.13
checksum: 23c14767ea77e850a4656a3a1023315d3144639040e1172bc89a090c09498300fa2b13c18f3ef6aabbdf77a98a3c2b4bd84d2da64397fcfa94457ae525c24c13
languageName: node
linkType: hard
"next-auth@npm:4.22.5": "next-auth@npm:4.22.5":
version: 4.22.5 version: 4.22.5
resolution: "next-auth@npm:4.22.5" resolution: "next-auth@npm:4.22.5"