* add eslint config * migrate autoLock to features * migrate teamService to features * migrate userCreationService * migrate insights services to features * migrate ProfileRepository * update imports * migrate filter segmen tests * migrate filter segment repository * migrate getBusyTimes * migrate getLocaleFromRequest * refactor csvUtils * make filename clearer * migrate getLuckyUser integration test * migrate autoLock test to features * wip * refactors * migrate useBookerUrl * migrate more * wip * Migrate eventTypeRepository * membership repository * update imports * update imports * migrate * move organization repository * update imports * update imports * wip * wip * wip * wip * wip * wip * wip * wip * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix tests * fix type checks * fix * fix * migrate * update imports * fix tests * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix
178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
import type { RatelimitResponse } from "@unkey/ratelimit";
|
||
|
||
import { RedisService } from "@calcom/features/redis/RedisService";
|
||
import logger from "@calcom/lib/logger";
|
||
import prisma from "@calcom/prisma";
|
||
|
||
import { hashAPIKey } from "./apiKeys";
|
||
|
||
// This is the number of times a user can exceed the rate limit before being locked
|
||
const DEFAULT_AUTOLOCK_THRESHOLD = 5;
|
||
// This is the duration of the rate limit check window
|
||
const DEFAULT_CHECK_THRESHOLD_WINDOW_DURATION = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||
|
||
interface HandleAutoLockInput {
|
||
identifier: string;
|
||
identifierType: "email" | "userId" | "SMS" | "apiKey";
|
||
rateLimitResponse: RatelimitResponse;
|
||
identifierKeyword?: string; // For instances where we have like "addSecondaryEmail.${email}"
|
||
autolockThreshold?: number;
|
||
autolockDuration?: number; // in milliseconds
|
||
}
|
||
|
||
export enum LockReason {
|
||
RATE_LIMIT = "Auto-locking user due to rate limit exceeded",
|
||
SPAM_WORKFLOW_BODY = "Auto-locking user due to spam detected in workflow body",
|
||
}
|
||
|
||
const log = logger.getSubLogger({ prefix: ["[autoLock]"] });
|
||
|
||
/**
|
||
* The "Requests to Hit Limit × Threshold" shows how many requests would be needed to trigger an auto-lock if a user consistently hits
|
||
* its their rate limit. For example, in the "core" * namespace, a user would need to make at least 50 requests (10 limit × 5 threshold)
|
||
* within the 30-minute window to get auto-locked.
|
||
*/
|
||
export async function handleAutoLock({
|
||
identifier: _identifier,
|
||
identifierType,
|
||
rateLimitResponse,
|
||
identifierKeyword,
|
||
autolockThreshold = DEFAULT_AUTOLOCK_THRESHOLD,
|
||
autolockDuration = DEFAULT_CHECK_THRESHOLD_WINDOW_DURATION,
|
||
}: HandleAutoLockInput): Promise<boolean> {
|
||
const { success, remaining } = rateLimitResponse;
|
||
|
||
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
|
||
|
||
if (!UPSTASH_ENV_FOUND) {
|
||
log.warn("Skipping auto lock because UPSTASH env variables are not set");
|
||
return false;
|
||
}
|
||
|
||
const identifier = identifierKeyword
|
||
? _identifier.toString().replace(`${identifierKeyword}.`, "")
|
||
: _identifier;
|
||
|
||
if (!success && remaining <= 0) {
|
||
const redis = new RedisService();
|
||
const lockKey = `autolock:${identifierType}${
|
||
identifierKeyword ? `:${identifierKeyword}` : ""
|
||
}:${identifier}.count`;
|
||
|
||
try {
|
||
const count = await redis.get(lockKey);
|
||
const currentCount = count ? parseInt(count.toString(), 10) : 0;
|
||
|
||
log.info(
|
||
`Rate limit exceeded for ${identifierType}: ${identifier}. Current count: ${currentCount}/${autolockThreshold}`
|
||
);
|
||
|
||
// If they have exceeded the threshold, lock them
|
||
if (currentCount + 1 >= autolockThreshold) {
|
||
log.warn(
|
||
`Auto-locking ${identifierType}: ${identifier}. Threshold reached: ${
|
||
currentCount + 1
|
||
}/${autolockThreshold}`
|
||
);
|
||
await lockUser(identifierType, identifier, LockReason.RATE_LIMIT);
|
||
await redis.del(lockKey);
|
||
return true;
|
||
}
|
||
|
||
await redis.set(lockKey, (currentCount + 1).toString());
|
||
await redis.expire(lockKey, Math.floor(autolockDuration / 1000));
|
||
return false;
|
||
} catch (err) {
|
||
if (err instanceof Error && err.message === "No user found for this API key.") {
|
||
log.error(`Error in auto-lock: No user found for API key: ${identifier}`);
|
||
throw err;
|
||
}
|
||
log.error(`Error in auto-lock process: ${err instanceof Error ? err.message : String(err)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
export async function lockUser(identifierType: string, identifier: string, lockReason: LockReason) {
|
||
if (!identifier) {
|
||
return;
|
||
}
|
||
|
||
type UserType = {
|
||
id: number;
|
||
email: string;
|
||
username: string | null;
|
||
} | null;
|
||
|
||
let user: UserType = null;
|
||
|
||
switch (identifierType) {
|
||
case "userId":
|
||
user = await prisma.user.update({
|
||
where: { id: Number(identifier) },
|
||
data: { locked: true },
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
});
|
||
break;
|
||
case "email":
|
||
user = await prisma.user.update({
|
||
where: { email: identifier },
|
||
data: { locked: true },
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
});
|
||
break;
|
||
case "apiKey":
|
||
const hashedApiKey = hashAPIKey(identifier);
|
||
const apiKey = await prisma.apiKey.findUnique({
|
||
where: { hashedKey: hashedApiKey },
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!apiKey?.user) {
|
||
throw new Error("No user found for this API key.");
|
||
}
|
||
|
||
user = await prisma.user.update({
|
||
where: { id: apiKey.user.id },
|
||
data: { locked: true },
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
});
|
||
break;
|
||
// Leaving SMS here but it is handled differently via checkRateLimitForSMS that auto locks
|
||
case "SMS":
|
||
break;
|
||
default:
|
||
throw new Error("Invalid identifier type for locking");
|
||
}
|
||
|
||
if (user && process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||
log.warn(lockReason, {
|
||
userId: user.id,
|
||
email: user.email,
|
||
username: user.username,
|
||
});
|
||
}
|
||
}
|