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:
Erik
2024-04-26 13:14:13 +00:00
committed by GitHub
co-authored by Alex van Andel Morgan
parent c28870e029
commit ceb555460e
14 changed files with 142 additions and 31 deletions
+1 -1
View File
@@ -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",
+24 -14
View File
@@ -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
+1
View File
@@ -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,
});
+2 -1
View File
@@ -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([
+1 -1
View File
@@ -48180,4 +48180,4 @@ __metadata:
resolution: "zwitch@npm:2.0.4"
checksum: f22ec5fc2d5f02c423c93d35cdfa83573a3a3bd98c66b927c368ea4d0e7252a500df2a90a6b45522be536a96a73404393c958e945fdba95e6832c200791702b6
languageName: node
linkType: hard
linkType: hard