Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
114 changes: 94 additions & 20 deletions apps/webapp/app/routes/login.magic/route.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -71,26 +83,88 @@ 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 clientIp = request.headers.get("x-forwarded-for");

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),
},
});
}
}
}

Expand Down
96 changes: 96 additions & 0 deletions apps/webapp/app/services/magicLinkRateLimiter.server.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
const result = await magicLinkIpRateLimiter.limit(ip);

if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MagicLinkRateLimitError(retryAfter);
}
}
Loading