Files
calendar/packages/lib/autoLock.ts
T
49cb86dfe9 fix: add improved logging and decrease ttl (#19979)
* add improved logging and decrease ttl

* fix test TTL time

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-03-12 10:26:07 +00:00

173 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { RatelimitResponse } from "@unkey/ratelimit";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { RedisService } from "@calcom/features/redis/RedisService";
import prisma from "@calcom/prisma";
import logger from "./logger";
// 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
}
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);
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;
}
async function lockUser(identifierType: string, identifier: string) {
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("Auto-locking user due to rate limit exceeded", {
userId: user.id,
email: user.email,
username: user.username,
});
}
}