Skip to content

Commit ddebe4d

Browse files
authored
feat(webapp): rate limit magic-link login attempts (#2568)
* feat(webapp): rate limit magic-link login attempts Adds a simple rate limiter to the login with magic link flow. Similar implementation to the MFA rate limits. * Fix error message * Add an env var feature flags for login rate limiting * Use BoolEnv instead of `0`/`1` * Parse xff properly
1 parent 09d51c6 commit ddebe4d

File tree

3 files changed

+199
-20
lines changed

3 files changed

+199
-20
lines changed

apps/webapp/app/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const EnvironmentSchema = z
5959
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
6060
REMIX_APP_PORT: z.string().optional(),
6161
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
62+
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
6263
APP_ORIGIN: z.string().default("http://localhost:3030"),
6364
API_ORIGIN: z.string().optional(),
6465
STREAM_ORIGIN: z.string().optional(),

apps/webapp/app/routes/login.magic/route.tsx

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid";
22
import { InboxArrowDownIcon } from "@heroicons/react/24/solid";
3-
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
4-
import { redirect } from "@remix-run/node";
3+
import {
4+
redirect,
5+
type ActionFunctionArgs,
6+
type LoaderFunctionArgs,
7+
type MetaFunction,
8+
} from "@remix-run/node";
59
import { Form, useNavigation } from "@remix-run/react";
610
import { typedjson, useTypedLoaderData } from "remix-typedjson";
711
import { z } from "zod";
@@ -18,6 +22,14 @@ import { Spinner } from "~/components/primitives/Spinner";
1822
import { TextLink } from "~/components/primitives/TextLink";
1923
import { authenticator } from "~/services/auth.server";
2024
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
25+
import {
26+
checkMagicLinkEmailRateLimit,
27+
checkMagicLinkEmailDailyRateLimit,
28+
MagicLinkRateLimitError,
29+
checkMagicLinkIpRateLimit,
30+
} from "~/services/magicLinkRateLimiter.server";
31+
import { logger, tryCatch } from "@trigger.dev/core/v3";
32+
import { env } from "~/env.server";
2133

2234
export const meta: MetaFunction = ({ matches }) => {
2335
const parentMeta = matches
@@ -71,29 +83,99 @@ export async function action({ request }: ActionFunctionArgs) {
7183

7284
const payload = Object.fromEntries(await clonedRequest.formData());
7385

74-
const { action } = z
75-
.object({
76-
action: z.enum(["send", "reset"]),
77-
})
86+
const data = z
87+
.discriminatedUnion("action", [
88+
z.object({
89+
action: z.literal("send"),
90+
email: z.string().trim().toLowerCase(),
91+
}),
92+
z.object({
93+
action: z.literal("reset"),
94+
}),
95+
])
7896
.parse(payload);
7997

80-
if (action === "send") {
81-
return authenticator.authenticate("email-link", request, {
82-
successRedirect: "/login/magic",
83-
failureRedirect: "/login/magic",
84-
});
85-
} else {
86-
const session = await getUserSession(request);
87-
session.unset("triggerdotdev:magiclink");
88-
89-
return redirect("/login/magic", {
90-
headers: {
91-
"Set-Cookie": await commitSession(session),
92-
},
93-
});
98+
switch (data.action) {
99+
case "send": {
100+
if (!env.LOGIN_RATE_LIMITS_ENABLED) {
101+
return authenticator.authenticate("email-link", request, {
102+
successRedirect: "/login/magic",
103+
failureRedirect: "/login/magic",
104+
});
105+
}
106+
107+
const { email } = data;
108+
const xff = request.headers.get("x-forwarded-for");
109+
const clientIp = extractClientIp(xff);
110+
111+
const [error] = await tryCatch(
112+
Promise.all([
113+
clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(),
114+
checkMagicLinkEmailRateLimit(email),
115+
checkMagicLinkEmailDailyRateLimit(email),
116+
])
117+
);
118+
119+
if (error) {
120+
if (error instanceof MagicLinkRateLimitError) {
121+
logger.warn("Login magic link rate limit exceeded", {
122+
clientIp,
123+
email,
124+
error,
125+
});
126+
} else {
127+
logger.error("Failed sending login magic link", {
128+
clientIp,
129+
email,
130+
error,
131+
});
132+
}
133+
134+
const errorMessage =
135+
error instanceof MagicLinkRateLimitError
136+
? "Too many magic link requests. Please try again shortly."
137+
: "Failed sending magic link. Please try again shortly.";
138+
139+
const session = await getUserSession(request);
140+
session.set("auth:error", {
141+
message: errorMessage,
142+
});
143+
144+
return redirect("/login/magic", {
145+
headers: {
146+
"Set-Cookie": await commitSession(session),
147+
},
148+
});
149+
}
150+
151+
return authenticator.authenticate("email-link", request, {
152+
successRedirect: "/login/magic",
153+
failureRedirect: "/login/magic",
154+
});
155+
}
156+
case "reset":
157+
default: {
158+
data.action satisfies "reset";
159+
160+
const session = await getUserSession(request);
161+
session.unset("triggerdotdev:magiclink");
162+
163+
return redirect("/login/magic", {
164+
headers: {
165+
"Set-Cookie": await commitSession(session),
166+
},
167+
});
168+
}
94169
}
95170
}
96171

172+
const extractClientIp = (xff: string | null) => {
173+
if (!xff) return null;
174+
175+
const parts = xff.split(",").map((p) => p.trim());
176+
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
177+
};
178+
97179
export default function LoginMagicLinkPage() {
98180
const { magicLinkSent, magicLinkError } = useTypedLoaderData<typeof loader>();
99181
const navigate = useNavigation();
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Ratelimit } from "@upstash/ratelimit";
2+
import { env } from "~/env.server";
3+
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
4+
import { singleton } from "~/utils/singleton";
5+
6+
export class MagicLinkRateLimitError extends Error {
7+
public readonly retryAfter: number;
8+
9+
constructor(retryAfter: number) {
10+
super("Magic link request rate limit exceeded.");
11+
this.retryAfter = retryAfter;
12+
}
13+
}
14+
15+
function getRedisClient() {
16+
return createRedisRateLimitClient({
17+
port: env.RATE_LIMIT_REDIS_PORT,
18+
host: env.RATE_LIMIT_REDIS_HOST,
19+
username: env.RATE_LIMIT_REDIS_USERNAME,
20+
password: env.RATE_LIMIT_REDIS_PASSWORD,
21+
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
22+
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
23+
});
24+
}
25+
26+
const magicLinkEmailRateLimiter = singleton(
27+
"magicLinkEmailRateLimiter",
28+
initializeMagicLinkEmailRateLimiter
29+
);
30+
31+
function initializeMagicLinkEmailRateLimiter() {
32+
return new RateLimiter({
33+
redisClient: getRedisClient(),
34+
keyPrefix: "auth:magiclink:email",
35+
limiter: Ratelimit.slidingWindow(3, "1 m"), // 3 requests per minute per email
36+
logSuccess: false,
37+
logFailure: true,
38+
});
39+
}
40+
41+
const magicLinkEmailDailyRateLimiter = singleton(
42+
"magicLinkEmailDailyRateLimiter",
43+
initializeMagicLinkEmailDailyRateLimiter
44+
);
45+
46+
function initializeMagicLinkEmailDailyRateLimiter() {
47+
return new RateLimiter({
48+
redisClient: getRedisClient(),
49+
keyPrefix: "auth:magiclink:email:daily",
50+
limiter: Ratelimit.slidingWindow(30, "1 d"), // 30 requests per day per email
51+
logSuccess: false,
52+
logFailure: true,
53+
});
54+
}
55+
56+
const magicLinkIpRateLimiter = singleton(
57+
"magicLinkIpRateLimiter",
58+
initializeMagicLinkIpRateLimiter
59+
);
60+
61+
function initializeMagicLinkIpRateLimiter() {
62+
return new RateLimiter({
63+
redisClient: getRedisClient(),
64+
keyPrefix: "auth:magiclink:ip",
65+
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per IP
66+
logSuccess: false,
67+
logFailure: true,
68+
});
69+
}
70+
71+
export async function checkMagicLinkEmailRateLimit(identifier: string): Promise<void> {
72+
const result = await magicLinkEmailRateLimiter.limit(identifier);
73+
74+
if (!result.success) {
75+
const retryAfter = new Date(result.reset).getTime() - Date.now();
76+
throw new MagicLinkRateLimitError(retryAfter);
77+
}
78+
}
79+
80+
export async function checkMagicLinkEmailDailyRateLimit(identifier: string): Promise<void> {
81+
const result = await magicLinkEmailDailyRateLimiter.limit(identifier);
82+
83+
if (!result.success) {
84+
const retryAfter = new Date(result.reset).getTime() - Date.now();
85+
throw new MagicLinkRateLimitError(retryAfter);
86+
}
87+
}
88+
89+
export async function checkMagicLinkIpRateLimit(ip: string): Promise<void> {
90+
const result = await magicLinkIpRateLimiter.limit(ip);
91+
92+
if (!result.success) {
93+
const retryAfter = new Date(result.reset).getTime() - Date.now();
94+
throw new MagicLinkRateLimitError(retryAfter);
95+
}
96+
}

0 commit comments

Comments
 (0)