diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 77ae2e8317..683a512c40 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -59,6 +59,7 @@ const EnvironmentSchema = z ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), REMIX_APP_PORT: z.string().optional(), LOGIN_ORIGIN: z.string().default("http://localhost:3030"), + LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true), APP_ORIGIN: z.string().default("http://localhost:3030"), API_ORIGIN: z.string().optional(), STREAM_ORIGIN: z.string().optional(), diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 04085ebd61..8c2015c5e6 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -1,7 +1,11 @@ import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid"; import { InboxArrowDownIcon } from "@heroicons/react/24/solid"; -import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import { + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -18,6 +22,14 @@ import { Spinner } from "~/components/primitives/Spinner"; import { TextLink } from "~/components/primitives/TextLink"; import { authenticator } from "~/services/auth.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; +import { + checkMagicLinkEmailRateLimit, + checkMagicLinkEmailDailyRateLimit, + MagicLinkRateLimitError, + checkMagicLinkIpRateLimit, +} from "~/services/magicLinkRateLimiter.server"; +import { logger, tryCatch } from "@trigger.dev/core/v3"; +import { env } from "~/env.server"; export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches @@ -71,29 +83,99 @@ export async function action({ request }: ActionFunctionArgs) { const payload = Object.fromEntries(await clonedRequest.formData()); - const { action } = z - .object({ - action: z.enum(["send", "reset"]), - }) + const data = z + .discriminatedUnion("action", [ + z.object({ + action: z.literal("send"), + email: z.string().trim().toLowerCase(), + }), + z.object({ + action: z.literal("reset"), + }), + ]) .parse(payload); - if (action === "send") { - return authenticator.authenticate("email-link", request, { - successRedirect: "/login/magic", - failureRedirect: "/login/magic", - }); - } else { - const session = await getUserSession(request); - session.unset("triggerdotdev:magiclink"); - - return redirect("/login/magic", { - headers: { - "Set-Cookie": await commitSession(session), - }, - }); + switch (data.action) { + case "send": { + if (!env.LOGIN_RATE_LIMITS_ENABLED) { + return authenticator.authenticate("email-link", request, { + successRedirect: "/login/magic", + failureRedirect: "/login/magic", + }); + } + + const { email } = data; + const xff = request.headers.get("x-forwarded-for"); + const clientIp = extractClientIp(xff); + + const [error] = await tryCatch( + Promise.all([ + clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(), + checkMagicLinkEmailRateLimit(email), + checkMagicLinkEmailDailyRateLimit(email), + ]) + ); + + if (error) { + if (error instanceof MagicLinkRateLimitError) { + logger.warn("Login magic link rate limit exceeded", { + clientIp, + email, + error, + }); + } else { + logger.error("Failed sending login magic link", { + clientIp, + email, + error, + }); + } + + const errorMessage = + error instanceof MagicLinkRateLimitError + ? "Too many magic link requests. Please try again shortly." + : "Failed sending magic link. Please try again shortly."; + + const session = await getUserSession(request); + session.set("auth:error", { + message: errorMessage, + }); + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } + + return authenticator.authenticate("email-link", request, { + successRedirect: "/login/magic", + failureRedirect: "/login/magic", + }); + } + case "reset": + default: { + data.action satisfies "reset"; + + const session = await getUserSession(request); + session.unset("triggerdotdev:magiclink"); + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } } } +const extractClientIp = (xff: string | null) => { + if (!xff) return null; + + const parts = xff.split(",").map((p) => p.trim()); + return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default +}; + export default function LoginMagicLinkPage() { const { magicLinkSent, magicLinkError } = useTypedLoaderData(); const navigate = useNavigation(); diff --git a/apps/webapp/app/services/magicLinkRateLimiter.server.ts b/apps/webapp/app/services/magicLinkRateLimiter.server.ts new file mode 100644 index 0000000000..2944e46796 --- /dev/null +++ b/apps/webapp/app/services/magicLinkRateLimiter.server.ts @@ -0,0 +1,96 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "~/env.server"; +import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server"; +import { singleton } from "~/utils/singleton"; + +export class MagicLinkRateLimitError extends Error { + public readonly retryAfter: number; + + constructor(retryAfter: number) { + super("Magic link request rate limit exceeded."); + this.retryAfter = retryAfter; + } +} + +function getRedisClient() { + return createRedisRateLimitClient({ + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }); +} + +const magicLinkEmailRateLimiter = singleton( + "magicLinkEmailRateLimiter", + initializeMagicLinkEmailRateLimiter +); + +function initializeMagicLinkEmailRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:email", + limiter: Ratelimit.slidingWindow(3, "1 m"), // 3 requests per minute per email + logSuccess: false, + logFailure: true, + }); +} + +const magicLinkEmailDailyRateLimiter = singleton( + "magicLinkEmailDailyRateLimiter", + initializeMagicLinkEmailDailyRateLimiter +); + +function initializeMagicLinkEmailDailyRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:email:daily", + limiter: Ratelimit.slidingWindow(30, "1 d"), // 30 requests per day per email + logSuccess: false, + logFailure: true, + }); +} + +const magicLinkIpRateLimiter = singleton( + "magicLinkIpRateLimiter", + initializeMagicLinkIpRateLimiter +); + +function initializeMagicLinkIpRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:ip", + limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per IP + logSuccess: false, + logFailure: true, + }); +} + +export async function checkMagicLinkEmailRateLimit(identifier: string): Promise { + const result = await magicLinkEmailRateLimiter.limit(identifier); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} + +export async function checkMagicLinkEmailDailyRateLimit(identifier: string): Promise { + const result = await magicLinkEmailDailyRateLimiter.limit(identifier); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} + +export async function checkMagicLinkIpRateLimit(ip: string): Promise { + const result = await magicLinkIpRateLimiter.limit(ip); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +}