feat: v2 rate limiting (#16882)

* refactor: pass redis storage to throttler guard

* chore: upgrade throttler to latest

* feat: ApiKey RateLimit table

* chore: upgrade redis storage throttler

* feat: rate limit by api key

* refactor: on delete api key cascade rate limit

* fix: permissions guard work with oauth credentials

* chore: set rate limit in env

* tests: throttler

* feat: include rate limit name in response

* fix: correctly handle multiple rate limits

* chore: remove unused import

* delete migrations

* chore: prisma migration

* doc

* dummy

* fix: permissions guard unit test

* refactor: remove route specific @Throttles

* fix: permissions guard
This commit is contained in:
Lauris Skraucis
2024-10-01 12:01:56 +00:00
committed by GitHub
parent 9d3be8de03
commit 4b6a389212
15 changed files with 747 additions and 80 deletions
+373 -14
View File
@@ -1,26 +1,385 @@
import { AppModule } from "@/app.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
describe("AppController (e2e)", () => {
let app: INestApplication;
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client";
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
describe("AppController", () => {
describe("Rate limiting", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let rateLimitRepositoryFixture: RateLimitRepositoryFixture;
const userEmail = "app-rate-limits-e2e@api.com";
let user: User;
app = moduleFixture.createNestApplication();
await app.init();
});
let organization: Team;
let oAuthClient: PlatformOAuthClient;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
it("/ (GET)", () => {
return request(app.getHttpServer()).get("/health").expect("OK");
});
let apiKeyString: string;
afterAll(async () => {
await app.close();
let rateLimit: RateLimit;
let apiKeyStringWithRateLimit: string;
let apiKeyStringWithMultipleLimits: string;
let firstRateLimitWithMultipleLimits: RateLimit;
let secondRateLimitWithMultipleLimits: RateLimit;
const mockDefaultLimit = 5;
const mockDefaultTtl = 2500;
const mockDefaultBlockDuration = 5000;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
}).compile();
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit);
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl);
jest
.spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration")
.mockReturnValue(mockDefaultBlockDuration);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `cal_test_${keyString}`;
rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef);
const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey(
user.id,
null
);
apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`;
rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000);
const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } =
await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`;
firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
"short",
apiKeyWithMultipleLimits.id,
1000,
2,
2000
);
secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
"long",
apiKeyWithMultipleLimits.id,
2000,
3,
4000
);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({ name: "ecorp" });
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
oAuthClient = await createOAuthClient(organization.id);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
app = moduleRef.createNestApplication();
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 1023,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it(
"api key with default rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = mockDefaultLimit;
const blockDuration = mockDefaultBlockDuration;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyString}` })
.expect(200);
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(429);
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
},
15 * 1000
);
it(
"api key with custom rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = rateLimit.limit;
const blockDuration = rateLimit.blockDuration;
const name = rateLimit.name;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` })
.expect(200);
expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
.expect(429);
expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0");
expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
.expect(200);
expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
},
15 * 1000
);
it(
"api key with multiple rate limits - should enforce both short and long rate limits",
async () => {
const shortLimit = firstRateLimitWithMultipleLimits.limit;
const longLimit = secondRateLimitWithMultipleLimits.limit;
const shortName = firstRateLimitWithMultipleLimits.name;
const longName = secondRateLimitWithMultipleLimits.name;
const shortBlock = firstRateLimitWithMultipleLimits.blockDuration;
const longBlock = secondRateLimitWithMultipleLimits.blockDuration;
let requestsMade = 0;
// note(Lauris): exhaust short limit to have remaining 0 for it
for (let i = 1; i <= shortLimit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
requestsMade++;
expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0);
expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
}
// note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it
for (let i = requestsMade; i < longLimit; i++) {
const responseAfterShortLimit = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
requestsMade++;
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(
0
);
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - requestsMade).toString()
);
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
}
// note(Lauris): both have remaining 0 so now exceed both
const blockedResponseLong = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(429);
expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual(
firstRateLimitWithMultipleLimits.blockDuration / 1000
);
expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0");
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual(
secondRateLimitWithMultipleLimits.blockDuration / 1000
);
// note(Lauris): wait for short limit to reset
await new Promise((resolve) => setTimeout(resolve, shortBlock));
const responseAfterShortLimitReload = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
(shortLimit - 1).toString()
);
expect(
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`])
).toBeGreaterThan(0);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
longLimit.toString()
);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - requestsMade).toString()
);
expect(
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`])
).toBeGreaterThan(0);
// note(Lauris): wait for long limit to reset
await new Promise((resolve) => setTimeout(resolve, longBlock));
const responseAfterLongLimitReload = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
(shortLimit - 1).toString()
);
expect(
Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`])
).toBeGreaterThan(0);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
longLimit.toString()
);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - 1).toString()
);
expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(
0
);
},
30 * 1000
);
it(
"non api key with default rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = mockDefaultLimit;
const blockDuration = mockDefaultBlockDuration;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200);
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(429);
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200);
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
},
15 * 1000
);
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userEmail);
await organizationsRepositoryFixture.delete(organization.id);
await app.close();
});
});
});
+15 -5
View File
@@ -13,12 +13,12 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -34,16 +34,19 @@ import { AppController } from "./app.controller";
BullModule.forRoot({
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
}),
// Rate limiting here is handled by the CustomThrottlerGuard
ThrottlerModule.forRootAsync({
imports: [RedisModule],
inject: [RedisService],
useFactory: (redisService: RedisService) => ({
// note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one
// entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule
// without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc.
// So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits.
throttlers: [
{
name: "long",
ttl: seconds(60), // Time to live for the long period in seconds
limit: 120, // Maximum number of requests within the long ttl
name: "dummy",
ttl: seconds(60),
limit: 120,
},
],
storage: new ThrottlerStorageRedisService(redisService.redis),
@@ -56,6 +59,13 @@ import { AppController } from "./app.controller";
],
controllers: [AppController],
providers: [
{
provide: ThrottlerStorageRedisService,
useFactory: (redisService: RedisService) => {
return new ThrottlerStorageRedisService(redisService.redis);
},
inject: [RedisService],
},
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
@@ -23,8 +23,7 @@ import {
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
@@ -73,7 +72,6 @@ export class SchedulesController_2024_04_15 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
@@ -18,7 +18,6 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
@@ -105,7 +104,6 @@ export class SchedulesController_2024_06_11 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
@ApiOperation({ summary: "Get a schedule" })
async getSchedule(
@GetUser() user: UserWithProfile,
+3
View File
@@ -20,6 +20,9 @@ export type Environment = {
GET_LICENSE_KEY_URL: string;
API_KEY_PREFIX: string;
DOCS_URL: string;
RATE_LIMIT_DEFAULT_TTL_MS: number;
RATE_LIMIT_DEFAULT_LIMIT: number;
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+160 -11
View File
@@ -1,42 +1,191 @@
import { isApiKey } from "@/lib/api-key";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModuleOptions, ThrottlerStorage } from "@nestjs/throttler";
import { Request } from "express";
import {
ThrottlerGuard,
ThrottlerException,
ThrottlerRequest,
ThrottlerModuleOptions,
seconds,
} from "@nestjs/throttler";
import { Request, Response } from "express";
import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
const rateLimitSchema = z.object({
name: z.string(),
limit: z.number(),
ttl: z.number(),
blockDuration: z.number(),
});
type RateLimitType = z.infer<typeof rateLimitSchema>;
const rateLimitsSchema = z.array(rateLimitSchema);
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
private logger = new Logger("CustomThrottlerGuard");
private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", 60 * 1000));
private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120));
private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", 60 * 1000));
constructor(
options: ThrottlerModuleOptions,
storageService: ThrottlerStorage,
@Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService,
reflector: Reflector,
private readonly config: ConfigService
private readonly dbRead: PrismaReadService
) {
super(options, storageService, reflector);
this.storageService = storageService;
}
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
if (tracker.startsWith("api_key_")) {
return this.handleApiKeyRequest(tracker, response);
} else {
return this.handleNonApiKeyRequest(tracker, response);
}
}
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
let allLimitsBlocked = true;
for (const rateLimit of rateLimits) {
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (!isBlocked) {
allLimitsBlocked = false;
}
}
if (allLimitsBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
}
return true;
}
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit();
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
}
return true;
}
private getDefaultRateLimit() {
return {
name: "default",
limit: this.getDefaultLimit(),
ttl: this.getDefaultTtl(),
blockDuration: this.getDefaultBlockDuration(),
};
}
getDefaultLimit() {
return this.defaultLimit;
}
getDefaultTtl() {
return this.defaultTttl;
}
getDefaultBlockDuration() {
return this.defaultBlockDuration;
}
private async getRateLimitsForApiKeyTracker(tracker: string) {
const cacheKey = `rate_limit:${tracker}`;
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) {
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
}
const apiKey = tracker.replace("api_key_", "");
let rateLimits: RateLimitType[];
const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({
where: { hashedKey: apiKey },
select: { id: true },
});
if (!apiKeyRecord) {
throw new UnauthorizedException("Invalid API Key");
}
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
where: { apiKeyId: apiKeyRecord.id },
select: { name: true, limit: true, ttl: true, blockDuration: true },
});
if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit()];
}
await this.storageService.redis.setex(cacheKey, 3600, JSON.stringify(rateLimits));
return rateLimits;
}
private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) {
const { name, limit, ttl, blockDuration } = rateLimit;
const key = `${tracker}:${limit}:${ttl}`;
const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment(
key,
ttl,
limit,
blockDuration,
name
);
const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1);
response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit);
response.setHeader(
`X-RateLimit-Remaining-${nameFirstUpper}`,
timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)
);
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.log(`Rate limit for ${tracker} incremented. Remaining: ${limit - totalHits}`);
return { isBlocked };
}
protected async getTracker(request: Request): Promise<string> {
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
if (authorizationHeader) {
return isApiKey(authorizationHeader, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? `api_key_${authorizationHeader}`
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
return isApiKey(authorizationHeader, apiKeyPrefix)
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
: `access_token_${authorizationHeader}`;
}
const oauthClientId = request.get(X_CAL_CLIENT_ID);
if (oauthClientId) {
return oauthClientId;
return `oauth_client_${oauthClientId}`;
}
if (request.ip) {
return request.ip;
return `ip_${request.ip}`;
}
this.logger.log(`no tracker found: ${request.url}`);
@@ -1,3 +1,4 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
@@ -26,7 +27,8 @@ describe("PermissionsGuard", () => {
return null;
}
}),
})
}),
createMock<OAuthClientRepository>()
);
});
@@ -38,7 +40,7 @@ describe("PermissionsGuard", () => {
it("should return false", async () => {
const mockContext = createMockExecutionContext({});
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -51,7 +53,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -62,7 +64,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -73,7 +75,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -83,7 +85,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -94,7 +96,63 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
});
describe("when oauth id is provided", () => {
it("should return true for valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return true for multiple valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return true for empty Permissions decorator", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false for invalid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return false for a missing permission", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -1,11 +1,13 @@
import { isApiKey } from "@/lib/api-key";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { getToken } from "next-auth/jwt";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { hasPermissions } from "@calcom/platform-utils";
@Injectable()
@@ -13,7 +15,8 @@ export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private tokensRepository: TokensRepository,
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly oAuthClientRepository: OAuthClientRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,12 +30,13 @@ export class PermissionsGuard implements CanActivate {
const authString = request.get("Authorization")?.replace("Bearer ", "");
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
if (nextAuthToken) {
return true;
}
if (!authString) {
if (!authString && !oAuthClientId) {
return false;
}
@@ -41,7 +45,9 @@ export class PermissionsGuard implements CanActivate {
return true;
}
const oAuthClientPermissions = await this.getOAuthClientPermissions(authString);
const oAuthClientPermissions = authString
? await this.getOAuthClientPermissionsByAccessToken(authString)
: await this.getOAuthClientPermissionsById(oAuthClientId);
if (!oAuthClientPermissions) {
return false;
@@ -50,8 +56,13 @@ export class PermissionsGuard implements CanActivate {
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
}
async getOAuthClientPermissions(accessToken: string) {
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
return oAuthClient?.permissions;
}
async getOAuthClientPermissionsById(id: string) {
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
return oAuthClient?.permissions;
}
}
@@ -2,7 +2,6 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle, seconds } from "@nestjs/throttler";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -52,7 +51,6 @@ export class SlotsController {
@Get("/available")
@ApiOperation({ summary: "Get available slots" })
@Throttle({ default: { limit: 300, ttl: seconds(60) } }) // allow 300 requests per minute
async getAvailableSlots(
@Query() query: GetAvailableSlotsInput,
@Req() req: ExpressRequest