Skip to content

Commit 13a40d5

Browse files
committed
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.
1 parent 3ceea77 commit 13a40d5

File tree

2 files changed

+182
-20
lines changed

2 files changed

+182
-20
lines changed

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

Lines changed: 86 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,13 @@ 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";
2132

2233
export const meta: MetaFunction = ({ matches }) => {
2334
const parentMeta = matches
@@ -71,26 +82,81 @@ export async function action({ request }: ActionFunctionArgs) {
7182

7283
const payload = Object.fromEntries(await clonedRequest.formData());
7384

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

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-
});
97+
switch (data.action) {
98+
case "send": {
99+
const { email } = data;
100+
const clientIp = request.headers.get("x-forwarded-for");
101+
102+
const [error] = await tryCatch(
103+
Promise.all([
104+
clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(),
105+
checkMagicLinkEmailRateLimit(email),
106+
checkMagicLinkEmailDailyRateLimit(email),
107+
])
108+
);
109+
110+
if (error) {
111+
if (error instanceof MagicLinkRateLimitError) {
112+
logger.warn("Login magic link rate limit exceeded", {
113+
clientIp,
114+
email,
115+
error,
116+
});
117+
} else {
118+
logger.error("Failed sending login magic link", {
119+
clientIp,
120+
email,
121+
error,
122+
});
123+
}
124+
125+
const errorMessage =
126+
error instanceof MagicLinkRateLimitError
127+
? "Failed sending magic link. Please try again shortly."
128+
: "Too many magic link requests. Please try again shortly.";
129+
130+
const session = await getUserSession(request);
131+
session.set("auth:error", {
132+
message: errorMessage,
133+
});
134+
135+
return redirect("/login/magic", {
136+
headers: {
137+
"Set-Cookie": await commitSession(session),
138+
},
139+
});
140+
}
141+
142+
return authenticator.authenticate("email-link", request, {
143+
successRedirect: "/login/magic",
144+
failureRedirect: "/login/magic",
145+
});
146+
}
147+
case "reset":
148+
default: {
149+
data.action satisfies "reset";
150+
151+
const session = await getUserSession(request);
152+
session.unset("triggerdotdev:magiclink");
153+
154+
return redirect("/login/magic", {
155+
headers: {
156+
"Set-Cookie": await commitSession(session),
157+
},
158+
});
159+
}
94160
}
95161
}
96162

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)