Skip to content

Commit 71aa702

Browse files
committed
Implement rate limiting on the mfa validation endpoint
1 parent 54d0b39 commit 71aa702

File tree

3 files changed

+64
-5
lines changed

3 files changed

+64
-5
lines changed

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { commitSession, getUserSession, sessionStorage } from "~/services/sessio
1919
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
2020
import { redirectWithErrorMessage } from "~/models/message.server";
2121
import { ServiceValidationError } from "~/v3/services/baseService.server";
22+
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server";
2223

2324
export const meta: MetaFunction = ({ matches }) => {
2425
const parentMeta = matches
@@ -104,6 +105,9 @@ export async function action({ request }: ActionFunctionArgs) {
104105
});
105106
}
106107

108+
// Rate limit MFA verification attempts
109+
await checkMfaRateLimit(pendingUserId);
110+
107111
const result = await mfaService.verifyRecoveryCodeForLogin(pendingUserId, recoveryCode);
108112

109113
if (!result.success) {
@@ -125,6 +129,9 @@ export async function action({ request }: ActionFunctionArgs) {
125129
});
126130
}
127131

132+
// Rate limit MFA verification attempts
133+
await checkMfaRateLimit(pendingUserId);
134+
128135
const result = await mfaService.verifyTotpForLogin(pendingUserId, mfaCode);
129136

130137
if (!result.success) {
@@ -144,6 +151,15 @@ export async function action({ request }: ActionFunctionArgs) {
144151
if (error instanceof ServiceValidationError) {
145152
return redirectWithErrorMessage("/login", request, error.message);
146153
}
154+
155+
if (error instanceof MfaRateLimitError) {
156+
const session = await getUserSession(request);
157+
session.set("auth:error", { message: error.message });
158+
return redirect("/login/mfa", {
159+
headers: { "Set-Cookie": await commitSession(session) },
160+
});
161+
}
162+
147163
throw error;
148164
}
149165
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 const mfaRateLimiter = singleton("mfaRateLimiter", initializeMfaRateLimiter);
7+
8+
function initializeMfaRateLimiter() {
9+
const redisClient = createRedisRateLimitClient({
10+
port: env.RATE_LIMIT_REDIS_PORT,
11+
host: env.RATE_LIMIT_REDIS_HOST,
12+
username: env.RATE_LIMIT_REDIS_USERNAME,
13+
password: env.RATE_LIMIT_REDIS_PASSWORD,
14+
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
15+
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
16+
});
17+
18+
return new RateLimiter({
19+
redisClient,
20+
keyPrefix: "mfa:validation",
21+
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 attempts per minute
22+
logSuccess: false, // Don't log successful attempts for privacy
23+
logFailure: true, // Log rate limit violations for security monitoring
24+
});
25+
}
26+
27+
export class MfaRateLimitError extends Error {
28+
public readonly retryAfter: number;
29+
30+
constructor(retryAfter: number) {
31+
super(`MFA validation rate limit exceeded.`);
32+
this.retryAfter = retryAfter;
33+
}
34+
}
35+
36+
/**
37+
* Check if the user can attempt MFA validation
38+
* @param userId - The user ID to rate limit
39+
* @throws {MfaRateLimitError} If rate limit is exceeded
40+
*/
41+
export async function checkMfaRateLimit(userId: string): Promise<void> {
42+
const result = await mfaRateLimiter.limit(userId);
43+
44+
if (!result.success) {
45+
const retryAfter = new Date(result.reset).getTime() - Date.now();
46+
throw new MfaRateLimitError(retryAfter);
47+
}
48+
}

apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,11 @@ export class MultiFactorAuthenticationService {
263263

264264
const secret = secretResult.secret;
265265

266-
console.log("secret", secret);
267-
console.log("totpCode", totpCode);
268-
269266
const isValid = await createOTP(secret, {
270267
digits: 6,
271268
period: 30,
272269
}).verify(totpCode);
273270

274-
console.log("isValid", isValid);
275-
276271
return isValid;
277272
}
278273

0 commit comments

Comments
 (0)