feat: auto lock (#18630)
* 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>
This commit is contained in:
co-authored by
Alex van Andel
parent
4855b6b40f
commit
b4a51d3560
@@ -1,9 +1,12 @@
|
||||
import type { RatelimitResponse } from "@unkey/ratelimit";
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiResponse, NextApiRequest } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { handleAutoLock } from "@calcom/lib/autoLock";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey";
|
||||
|
||||
@@ -14,6 +17,10 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/autoLock", () => ({
|
||||
handleAutoLock: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("rateLimitApiKey middleware", () => {
|
||||
it("should return 401 if no apiKey is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
@@ -55,17 +62,20 @@ describe("rateLimitApiKey middleware", () => {
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
const rateLimiterResponse = {
|
||||
const rateLimiterResponse: RatelimitResponse = {
|
||||
limit: 100,
|
||||
remaining: 99,
|
||||
reset: Date.now(),
|
||||
success: true,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
});
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
@@ -89,4 +99,135 @@ describe("rateLimitApiKey middleware", () => {
|
||||
expect(res._getStatusCode()).toBe(429);
|
||||
expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" });
|
||||
});
|
||||
|
||||
it("should lock API key when rate limit is repeatedly exceeded", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
const rateLimiterResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 100,
|
||||
reset: Date.now(),
|
||||
};
|
||||
|
||||
// Mock rate limiter to trigger the onRateLimiterResponse callback
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
}
|
||||
);
|
||||
|
||||
// Mock handleAutoLock to indicate the key was locked
|
||||
vi.mocked(handleAutoLock).mockResolvedValueOnce(true);
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(handleAutoLock).toHaveBeenCalledWith({
|
||||
identifier: "test-key",
|
||||
identifierType: "apiKey",
|
||||
rateLimitResponse: rateLimiterResponse,
|
||||
});
|
||||
|
||||
expect(res._getStatusCode()).toBe(429);
|
||||
expect(res._getJSONData()).toEqual({ message: "Too many requests" });
|
||||
});
|
||||
|
||||
it("should handle API key not found error during auto-lock", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
const rateLimiterResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 100,
|
||||
reset: Date.now(),
|
||||
};
|
||||
|
||||
// Mock rate limiter to trigger the onRateLimiterResponse callback
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
}
|
||||
);
|
||||
|
||||
// Mock handleAutoLock to throw a "No user found" error
|
||||
vi.mocked(handleAutoLock).mockRejectedValueOnce(new Error("No user found for this API key."));
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(handleAutoLock).toHaveBeenCalledWith({
|
||||
identifier: "test-key",
|
||||
identifierType: "apiKey",
|
||||
rateLimitResponse: rateLimiterResponse,
|
||||
});
|
||||
|
||||
expect(res._getStatusCode()).toBe(401);
|
||||
expect(res._getJSONData()).toEqual({ message: "No user found for this API key." });
|
||||
});
|
||||
|
||||
it("should continue if auto-lock returns false (not locked)", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
const rateLimiterResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 100,
|
||||
reset: Date.now(),
|
||||
};
|
||||
|
||||
// Mock rate limiter to trigger the onRateLimiterResponse callback
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
}
|
||||
);
|
||||
|
||||
// Mock handleAutoLock to indicate the key was not locked
|
||||
vi.mocked(handleAutoLock).mockResolvedValueOnce(false);
|
||||
|
||||
const next = vi.fn();
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, next);
|
||||
|
||||
expect(handleAutoLock).toHaveBeenCalledWith({
|
||||
identifier: "test-key",
|
||||
identifierType: "apiKey",
|
||||
rateLimitResponse: rateLimiterResponse,
|
||||
});
|
||||
|
||||
// Verify headers were set but request continued
|
||||
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle HttpError during rate limiting", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
// Mock checkRateLimitAndThrowError to throw HttpError
|
||||
vi.mocked(checkRateLimitAndThrowError).mockRejectedValueOnce(
|
||||
new HttpError({
|
||||
statusCode: 429,
|
||||
message: "Custom rate limit error",
|
||||
})
|
||||
);
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(429);
|
||||
expect(res._getJSONData()).toEqual({ message: "Custom rate limit error" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { handleAutoLock } from "@calcom/lib/autoLock";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
@@ -10,14 +12,34 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: req.query.apiKey as string,
|
||||
rateLimitingType: "api",
|
||||
onRateLimiterResponse: (response) => {
|
||||
onRateLimiterResponse: async (response) => {
|
||||
res.setHeader("X-RateLimit-Limit", response.limit);
|
||||
res.setHeader("X-RateLimit-Remaining", response.remaining);
|
||||
res.setHeader("X-RateLimit-Reset", response.reset);
|
||||
|
||||
try {
|
||||
const didLock = await handleAutoLock({
|
||||
identifier: req.query.apiKey as string, // Casting as this is verified in another middleware
|
||||
identifierType: "apiKey",
|
||||
rateLimitResponse: response,
|
||||
});
|
||||
|
||||
if (didLock) {
|
||||
return res.status(429).json({ message: "Too many requests" });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "No user found for this API key.") {
|
||||
return res.status(401).json({ message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(429).json({ message: "Rate limit exceeded" });
|
||||
if (error instanceof HttpError) {
|
||||
return res.status(error.statusCode).json({ message: error.message });
|
||||
}
|
||||
return res.status(429).json({ message: "Rate limit exceeded" });
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
+2
@@ -8,4 +8,6 @@ export interface IRedisService {
|
||||
lrange: <TResult = string>(key: string, start: number, end: number) => Promise<TResult[]>;
|
||||
|
||||
lpush: <TData>(key: string, ...elements: TData[]) => Promise<number>;
|
||||
|
||||
del: (key: string) => Promise<number>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ export class RedisService implements IRedisService {
|
||||
return this.redis.get(key);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
return this.redis.del(key);
|
||||
}
|
||||
|
||||
async set<TData>(key: string, value: TData): Promise<"OK" | TData | null> {
|
||||
// Implementation for setting value in Redis
|
||||
return this.redis.set(key, value);
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { RatelimitResponse } from "@unkey/ratelimit";
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { RedisService } from "@calcom/features/redis/RedisService";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { handleAutoLock } from "../autoLock";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("@calcom/features/redis/RedisService");
|
||||
vi.mock("@calcom/features/ee/api-keys/lib/apiKeys", () => ({
|
||||
hashAPIKey: vi.fn((key) => `hashed_${key}`),
|
||||
}));
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
user: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
setUser: vi.fn(),
|
||||
setTag: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("autoLock", () => {
|
||||
const mockRedis = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
expire: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(RedisService).mockImplementation(() => mockRedis as any);
|
||||
|
||||
// Mock environment variables
|
||||
process.env.UPSTASH_REDIS_REST_TOKEN = "test-token";
|
||||
process.env.UPSTASH_REDIS_REST_URL = "test-url";
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN = "sentry-dsn";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
delete process.env.UPSTASH_REDIS_REST_URL;
|
||||
delete process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
});
|
||||
|
||||
describe("handleAutoLock", () => {
|
||||
it("should return early if Upstash env variables are not set", async () => {
|
||||
delete process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
});
|
||||
expect(mockRedis.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle identifier with keyword correctly", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("0");
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "addSecondaryEmail.test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
identifierKeyword: "addSecondaryEmail",
|
||||
});
|
||||
|
||||
expect(mockRedis.get).toHaveBeenCalledWith("autolock:email:addSecondaryEmail:test@example.com.count");
|
||||
});
|
||||
|
||||
it("should increment counter when below threshold", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("2");
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith("autolock:email:test@example.com.count", "3");
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith("autolock:email:test@example.com.count", 3600);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should lock user when threshold is reached", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("4");
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
data: { locked: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
expect(mockRedis.del).toHaveBeenCalledWith("autolock:email:test@example.com.count");
|
||||
});
|
||||
|
||||
it("should respect custom threshold and duration", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("1");
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
autolockThreshold: 3,
|
||||
autolockDuration: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith("autolock:email:test@example.com.count", "2");
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith("autolock:email:test@example.com.count", 1800);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should lock API key and associated user", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("4");
|
||||
const testApiKey = "test_api_key_123";
|
||||
|
||||
// Mock API key lookup
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({
|
||||
hashedKey: `hashed_${testApiKey}`,
|
||||
user: { id: 456 },
|
||||
} as any);
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: testApiKey,
|
||||
identifierType: "apiKey",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
// Verify the API key was hashed
|
||||
expect(hashAPIKey).toHaveBeenCalledWith(testApiKey);
|
||||
|
||||
// Verify the associated user was locked
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 456 },
|
||||
data: { locked: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error when API key has no associated user", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("4"); // Over threshold to trigger lock
|
||||
const testApiKey = "test_api_key_123";
|
||||
|
||||
// Mock API key lookup with no user
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
// Expect handleAutoLock to throw the error from lockUser
|
||||
await expect(async () => {
|
||||
await handleAutoLock({
|
||||
identifier: testApiKey,
|
||||
identifierType: "apiKey",
|
||||
rateLimitResponse,
|
||||
});
|
||||
}).rejects.toThrow("No user found for this API key.");
|
||||
});
|
||||
|
||||
it("should not increment counter when rate limit is successful", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: true,
|
||||
remaining: 5,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
expect(mockRedis.get).not.toHaveBeenCalled();
|
||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should initialize counter when it doesn't exist", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith("autolock:email:test@example.com.count", "1");
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith("autolock:email:test@example.com.count", 3600);
|
||||
});
|
||||
|
||||
it("should handle Redis errors gracefully", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockRejectedValue(new Error("Redis connection error"));
|
||||
|
||||
const result = await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockUser", () => {
|
||||
it("should lock user by email", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("4");
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "test@example.com",
|
||||
identifierType: "email",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
data: { locked: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should lock user by userId", async () => {
|
||||
const rateLimitResponse: RatelimitResponse = {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
limit: 5,
|
||||
reset: 0,
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue("4");
|
||||
|
||||
await handleAutoLock({
|
||||
identifier: "123",
|
||||
identifierType: "userId",
|
||||
rateLimitResponse,
|
||||
});
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 123 },
|
||||
data: { locked: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export async function checkRateLimitAndThrowError({
|
||||
message: `Rate limit exceeded. Try again in ${secondsToWait} seconds.`,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function checkSMSRateLimit({
|
||||
|
||||
Reference in New Issue
Block a user