feat: Platform Rate limiting + access token caching (#14560)
* feat: Platform Rate limiting + access token caching * chore: changes * chore: cleanup * chore: remove yarnlock changes * chore: yarnlcok --------- Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
co-authored by
Alex van Andel
Morgan
parent
c28870e029
commit
ceb555460e
@@ -37,7 +37,7 @@
|
||||
"@nestjs/passport": "^10.0.2",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.1",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@sentry/node": "^7.86.0",
|
||||
"@sentry/tracing": "^7.86.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
|
||||
@@ -5,9 +5,13 @@ import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { EndpointsModule } from "@/modules/endpoints.module";
|
||||
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 { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { RouterModule } from "@nestjs/core";
|
||||
import { ThrottlerModule, seconds } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@@ -18,20 +22,26 @@ import { AppController } from "./app.controller";
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
// ThrottlerModule.forRootAsync({
|
||||
// imports: [ConfigModule],
|
||||
// inject: [ConfigService],
|
||||
// useFactory: (config: ConfigService<AppConfig>) => ({
|
||||
// throttlers: [
|
||||
// {
|
||||
// name: "short",
|
||||
// ttl: seconds(10),
|
||||
// limit: 3,
|
||||
// },
|
||||
// ],
|
||||
// storage: new ThrottlerStorageRedisService(config.get("db.redisUrl", { infer: true })),
|
||||
// }),
|
||||
// }),
|
||||
RedisModule,
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
throttlers: [
|
||||
{
|
||||
name: "short",
|
||||
ttl: seconds(10),
|
||||
limit: 3,
|
||||
},
|
||||
{
|
||||
name: "medium",
|
||||
ttl: seconds(30),
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
EndpointsModule,
|
||||
AuthModule,
|
||||
|
||||
@@ -2,12 +2,13 @@ import { BookingsController } from "@/ee/bookings/controllers/bookings.controlle
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TokensModule],
|
||||
imports: [PrismaModule, RedisModule, TokensModule],
|
||||
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
|
||||
controllers: [BookingsController],
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput } from "@calcom/platform-types";
|
||||
@@ -70,6 +71,7 @@ export class SchedulesController {
|
||||
|
||||
@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
|
||||
|
||||
@@ -27,6 +27,7 @@ const run = async () => {
|
||||
await app.listen(port);
|
||||
logger.log(`Application started on port: ${port}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error("Application crashed", {
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -6,13 +6,14 @@ import { ApiKeyAuthStrategy } from "@/modules/auth/strategies/api-key-auth/api-k
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
imports: [PassportModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule],
|
||||
imports: [PassportModule, RedisModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule],
|
||||
providers: [
|
||||
ApiKeyAuthStrategy,
|
||||
NextAuthGuard,
|
||||
|
||||
@@ -27,7 +27,10 @@ export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access-
|
||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
await this.oauthFlowService.validateAccessToken(accessToken);
|
||||
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
|
||||
if (!accessTokenValid) {
|
||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||
if (!client) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -20,6 +21,7 @@ import { Global, Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
|
||||
@@ -1,33 +1,70 @@
|
||||
import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthFlowService {
|
||||
private logger = new Logger("OAuthFlowService");
|
||||
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository //private readonly redisService: RedisIOService
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async propagateAccessToken(accessToken: string) {
|
||||
// this.logger.log("Propagating access token to redis", accessToken);
|
||||
// TODO propagate
|
||||
//this.redisService.redis.hset("access_tokens", accessToken,)
|
||||
return void 0;
|
||||
try {
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
let expiry = await this.tokensRepository.getAccessTokenExpiryDate(accessToken);
|
||||
|
||||
if (!expiry) {
|
||||
this.logger.warn(`Token for ${ownerId} had no expiry time, assuming it's new.`);
|
||||
expiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
}
|
||||
|
||||
const cacheKey = this._generateActKey(accessToken);
|
||||
await this.redisService.redis.hmset(cacheKey, {
|
||||
ownerId: ownerId,
|
||||
expiresAt: expiry?.toJSON(),
|
||||
});
|
||||
|
||||
await this.redisService.redis.expireat(cacheKey, Math.floor(expiry.getTime() / 1000));
|
||||
} catch (err) {
|
||||
this.logger.error("Access Token Propagation Failed, falling back to DB...", err);
|
||||
}
|
||||
}
|
||||
|
||||
async getOwnerId(accessToken: string) {
|
||||
return this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
const cacheKey = this._generateActKey(accessToken);
|
||||
|
||||
try {
|
||||
const ownerId = await this.redisService.redis.get(cacheKey);
|
||||
if (ownerId) {
|
||||
return Number.parseInt(ownerId);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn("Cache#getOwnerId fetch failed, falling back to DB...");
|
||||
}
|
||||
|
||||
const ownerIdFromDb = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
|
||||
|
||||
// await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
|
||||
void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
|
||||
|
||||
return ownerIdFromDb;
|
||||
}
|
||||
|
||||
async validateAccessToken(secret: string) {
|
||||
// status can be "CACHE_HIT" or "CACHE_MISS", MISS will most likely mean the token has expired
|
||||
// but we need to check the SQL db for it anyways.
|
||||
const { status } = await this.readFromCache(secret);
|
||||
const { status, cacheKey } = await this.readFromCache(secret);
|
||||
|
||||
if (status === "CACHE_HIT") {
|
||||
return true;
|
||||
@@ -43,12 +80,23 @@ export class OAuthFlowService {
|
||||
throw new TokenExpiredException();
|
||||
}
|
||||
|
||||
// we can't use a Promise#all or similar here because we care about execution order
|
||||
// however we can't allow caches to fail a validation hence the results are voided.
|
||||
void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
|
||||
void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
private async readFromCache(secret: string) {
|
||||
return { status: "CACHE_MISS" };
|
||||
const cacheKey = this._generateActKey(secret);
|
||||
const tokenData = await this.redisService.redis.hgetall(cacheKey);
|
||||
|
||||
if (tokenData && new Date() < new Date(tokenData.expiresAt)) {
|
||||
return { status: "CACHE_HIT", cacheKey };
|
||||
}
|
||||
|
||||
return { status: "CACHE_MISS", cacheKey };
|
||||
}
|
||||
|
||||
async exchangeAuthorizationToken(
|
||||
@@ -77,7 +125,7 @@ export class OAuthFlowService {
|
||||
authorizationToken.owner.id
|
||||
);
|
||||
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
|
||||
void this.propagateAccessToken(accessToken); // voided as we don't need to await
|
||||
void this.propagateAccessToken(accessToken); // void result, ignored.
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
@@ -113,4 +161,8 @@ export class OAuthFlowService {
|
||||
refreshToken: refreshToken.secret,
|
||||
};
|
||||
}
|
||||
|
||||
private _generateActKey(accessToken: string) {
|
||||
return `act_${accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
||||
|
||||
@Injectable()
|
||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
||||
// TODO: adapt if required for CF / AWS / FlightControl proxying.
|
||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
||||
return req.ips.length ? req.ips[0] : req.ip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
public redis: Redis;
|
||||
|
||||
constructor(readonly configService: ConfigService<AppConfig>) {
|
||||
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
||||
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
|
||||
|
||||
this.redis = new Redis(dbUrl);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.redis.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export class TokensRepository {
|
||||
// discard.
|
||||
}
|
||||
}
|
||||
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
|
||||
Reference in New Issue
Block a user