diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index d88131c3e1..f2579f4838 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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", diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index a589abe526..2d7dc5aeb5 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -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) => ({ - // 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, diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/bookings.module.ts index 9725cac786..c7472cafe7 100644 --- a/apps/api/v2/src/ee/bookings/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/bookings.module.ts @@ -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], }) diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts index 1e953a9f5a..b2860cfe5e 100644 --- a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts +++ b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts @@ -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 diff --git a/apps/api/v2/src/main.ts b/apps/api/v2/src/main.ts index e149793395..6f2d13429f 100644 --- a/apps/api/v2/src/main.ts +++ b/apps/api/v2/src/main.ts @@ -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, }); diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts index aae58abb10..06ef41ecae 100644 --- a/apps/api/v2/src/modules/auth/auth.module.ts +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -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, diff --git a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts index 53e055d2d2..dc57103c86 100644 --- a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts @@ -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) { diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts index 77a651b49a..9998f9e273 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -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, diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts index 24490dbfb7..be9d4fc555 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts @@ -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}`; + } } diff --git a/apps/api/v2/src/modules/proxy/proxy.guard.ts b/apps/api/v2/src/modules/proxy/proxy.guard.ts new file mode 100644 index 0000000000..16074913d4 --- /dev/null +++ b/apps/api/v2/src/modules/proxy/proxy.guard.ts @@ -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): Promise { + return req.ips.length ? req.ips[0] : req.ip; + } +} diff --git a/apps/api/v2/src/modules/redis/redis.module.ts b/apps/api/v2/src/modules/redis/redis.module.ts new file mode 100644 index 0000000000..bbe6472fc5 --- /dev/null +++ b/apps/api/v2/src/modules/redis/redis.module.ts @@ -0,0 +1,8 @@ +import { RedisService } from "@/modules/redis/redis.service"; +import { Module } from "@nestjs/common"; + +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/apps/api/v2/src/modules/redis/redis.service.ts b/apps/api/v2/src/modules/redis/redis.service.ts new file mode 100644 index 0000000000..ea55ec4638 --- /dev/null +++ b/apps/api/v2/src/modules/redis/redis.service.ts @@ -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) { + const dbUrl = configService.get("db.redisUrl", { infer: true }); + if (!dbUrl) throw new Error("Misconfigured Redis, halting."); + + this.redis = new Redis(dbUrl); + } + + async onModuleDestroy() { + await this.redis.disconnect(); + } +} diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts index 46b90a2ddc..80bbc027fd 100644 --- a/apps/api/v2/src/modules/tokens/tokens.repository.ts +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -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([ diff --git a/yarn.lock b/yarn.lock index 87dc219835..5e9bbcf258 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48180,4 +48180,4 @@ __metadata: resolution: "zwitch@npm:2.0.4" checksum: f22ec5fc2d5f02c423c93d35cdfa83573a3a3bd98c66b927c368ea4d0e7252a500df2a90a6b45522be536a96a73404393c958e945fdba95e6832c200791702b6 languageName: node - linkType: hard + linkType: hard \ No newline at end of file