* add delete to redis service * auto lock + tests * changes to autolock in api/book call * remove log clutter * add detection in api v1 * tpye changes * throw error and add tests for API * add response logic on locked + tests * type response properly i hope? * type fix * add tests for counter not existing + redis errors * remove IP - add sentry to track * rename symbol * fix type error * fix tests * remove sentry call spy * fix type on sentry setUser call --------- Co-authored-by: Alex van Andel <me@alexvanandel.com>
155 lines
4.3 KiB
TypeScript
155 lines
4.3 KiB
TypeScript
import * as Sentry from "@sentry/nextjs";
|
|
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";
|
|
|
|
const DEFAULT_AUTOLOCK_THRESHOLD = 5;
|
|
const DEFAULT_CHECK_THRESHOLD_WINDOW_DURATION = 60 * 60 * 1000; // 1 hour 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 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) {
|
|
console.log("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;
|
|
|
|
// If they have exceeded the threshold, lock them
|
|
if (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.") {
|
|
throw 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) {
|
|
Sentry.setUser({
|
|
id: user.id.toString(),
|
|
email: user.email,
|
|
username: user.username ?? undefined,
|
|
});
|
|
Sentry.setTag("admin_notify", true);
|
|
Sentry.setTag("auto_lock", true);
|
|
Sentry.captureMessage(`User ${user.email} has been locked due to suspicious activity.`, "warning");
|
|
}
|
|
}
|