diff --git a/.env.example b/.env.example index fa261ca..a2ab182 100644 --- a/.env.example +++ b/.env.example @@ -20,5 +20,6 @@ NEXTAUTH_SECRET="" FROM_EMAIL="hello@usesend.com" API_RATE_LIMIT=2 +AUTH_EMAIL_RATE_LIMIT=5 NEXT_PUBLIC_IS_CLOUD=true diff --git a/.env.selfhost.example b/.env.selfhost.example index f6aea2d..b5cee46 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -34,6 +34,7 @@ AWS_ACCESS_KEY="" DOCKER_OUTPUT=1 API_RATE_LIMIT=1 +AUTH_EMAIL_RATE_LIMIT=5 # used to send important error notification - optional DISCORD_WEBHOOK_URL="" diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts index 6de56e1..0d089e7 100644 --- a/apps/web/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,85 @@ import NextAuth from "next-auth"; import { authOptions } from "~/server/auth"; +import { env } from "~/env"; +import { getRedis } from "~/server/redis"; +import { logger } from "~/server/logger/log"; const handler = NextAuth(authOptions); -export { handler as GET, handler as POST }; + +export { handler as GET }; + +function getClientIp(req: Request): string | null { + const h = req.headers; + const direct = + h.get("x-forwarded-for") ?? + h.get("x-real-ip") ?? + h.get("cf-connecting-ip") ?? + h.get("x-client-ip") ?? + h.get("true-client-ip") ?? + h.get("fastly-client-ip") ?? + h.get("x-cluster-client-ip") ?? + null; + + let ip = direct?.split(",")[0]?.trim() ?? ""; + + if (!ip) { + const fwd = h.get("forwarded"); + if (fwd) { + const first = fwd.split(",")[0]; + const match = first?.match(/for=([^;]+)/i); + if (match && match[1]) { + const raw = match[1].trim().replace(/^"|"$/g, ""); + if (raw.startsWith("[")) { + const end = raw.indexOf("]"); + ip = end !== -1 ? raw.slice(1, end) : raw; + } else { + const parts = raw.split(":"); + if (parts.length > 0 && parts[0]) { + ip = + parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0]) + ? parts[0] + : raw; + } + } + } + } + } + + return ip || null; +} + +export async function POST(req: Request, ctx: any) { + if (env.AUTH_EMAIL_RATE_LIMIT > 0) { + const url = new URL(req.url); + if (url.pathname.endsWith("/signin/email")) { + try { + const ip = getClientIp(req); + if (!ip) { + logger.warn("Auth email rate limit skipped: missing client IP"); + return handler(req, ctx); + } + const redis = getRedis(); + const key = `auth-rl:${ip}`; + const ttl = 60; + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, ttl); + if (count > env.AUTH_EMAIL_RATE_LIMIT) { + logger.warn({ ip }, "Auth email rate limit exceeded"); + return Response.json( + { + error: { + code: "RATE_LIMITED", + message: "Too many requests", + }, + }, + { status: 429 } + ); + } + } catch (error) { + logger.error({ err: error }, "Auth email rate limit failed"); + } + } + } + return handler(req, ctx); +} diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 96abfd0..7687412 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -44,6 +44,10 @@ export const env = createEnv({ .string() .default("1") .transform((str) => parseInt(str, 10)), + AUTH_EMAIL_RATE_LIMIT: z + .string() + .default("0") + .transform((str) => parseInt(str, 10)), FROM_EMAIL: z.string().optional(), ADMIN_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), @@ -95,6 +99,7 @@ export const env = createEnv({ AWS_SES_ENDPOINT: process.env.AWS_SES_ENDPOINT, AWS_SNS_ENDPOINT: process.env.AWS_SNS_ENDPOINT, API_RATE_LIMIT: process.env.API_RATE_LIMIT, + AUTH_EMAIL_RATE_LIMIT: process.env.AUTH_EMAIL_RATE_LIMIT, NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD, ADMIN_EMAIL: process.env.ADMIN_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,