mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
fix: rate limit email send actions
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { requireAdmin } from "@/lib/require-auth";
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
@@ -38,6 +39,9 @@ const settingsSchema = z.object({
|
|||||||
smtpFromEmail: z.string().trim().email("发件邮箱格式不正确").optional().or(z.literal("")),
|
smtpFromEmail: z.string().trim().email("发件邮箱格式不正确").optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SMTP_TEST_LIMIT = 5;
|
||||||
|
const SMTP_TEST_WINDOW_SECONDS = 10 * 60;
|
||||||
|
|
||||||
const smtpTestEmailSchema = z.string().trim().email("请输入正确的测试邮箱");
|
const smtpTestEmailSchema = z.string().trim().email("请输入正确的测试邮箱");
|
||||||
const smtpTestSettingsSchema = settingsSchema.extend({
|
const smtpTestSettingsSchema = settingsSchema.extend({
|
||||||
smtpTestEmail: smtpTestEmailSchema,
|
smtpTestEmail: smtpTestEmailSchema,
|
||||||
@@ -62,6 +66,18 @@ function formatActionError(error: unknown, fallback: string) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertSmtpTestRateLimit(userId: string) {
|
||||||
|
const { success } = await rateLimit(
|
||||||
|
`ratelimit:smtp-test:${userId}`,
|
||||||
|
SMTP_TEST_LIMIT,
|
||||||
|
SMTP_TEST_WINDOW_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error("测试发信过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Awaited<ReturnType<typeof getAppConfig>>) {
|
function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Awaited<ReturnType<typeof getAppConfig>>) {
|
||||||
const smtpEnabled = parsed.smtpEnabled === "true";
|
const smtpEnabled = parsed.smtpEnabled === "true";
|
||||||
const emailVerificationRequired = parsed.emailVerificationRequired === "true";
|
const emailVerificationRequired = parsed.emailVerificationRequired === "true";
|
||||||
@@ -164,9 +180,11 @@ export async function saveAppSettings(formData: FormData): Promise<SettingsActio
|
|||||||
export async function testSmtpSettings(formData: FormData): Promise<SmtpTestActionResult> {
|
export async function testSmtpSettings(formData: FormData): Promise<SmtpTestActionResult> {
|
||||||
let parsed: z.infer<typeof smtpTestSettingsSchema>;
|
let parsed: z.infer<typeof smtpTestSettingsSchema>;
|
||||||
let next: Awaited<ReturnType<typeof persistAppSettings>>;
|
let next: Awaited<ReturnType<typeof persistAppSettings>>;
|
||||||
|
let adminUserId = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await requireAdmin();
|
const session = await requireAdmin();
|
||||||
|
adminUserId = session.user.id;
|
||||||
parsed = smtpTestSettingsSchema.parse(Object.fromEntries(formData));
|
parsed = smtpTestSettingsSchema.parse(Object.fromEntries(formData));
|
||||||
next = await persistAppSettings(session, parsed, "测试发信前更新系统设置");
|
next = await persistAppSettings(session, parsed, "测试发信前更新系统设置");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -177,6 +195,16 @@ export async function testSmtpSettings(formData: FormData): Promise<SmtpTestActi
|
|||||||
return { ok: false, settingsSaved: true, error: "测试发信前请先开启邮件服务" };
|
return { ok: false, settingsSaved: true, error: "测试发信前请先开启邮件服务" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertSmtpTestRateLimit(adminUserId);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
settingsSaved: true,
|
||||||
|
error: formatActionError(error, "测试发信过于频繁,请稍后再试"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendSmtpTestEmail(parsed.smtpTestEmail);
|
await sendSmtpTestEmail(parsed.smtpTestEmail);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -12,14 +12,36 @@ const emailSchema = z.object({
|
|||||||
email: z.string().trim().email("请输入正确的邮箱"),
|
email: z.string().trim().email("请输入正确的邮箱"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const EMAIL_ACTION_LIMIT = 3;
|
||||||
|
const EMAIL_ACTION_IP_LIMIT = 10;
|
||||||
|
const EMAIL_ACTION_WINDOW_SECONDS = 10 * 60;
|
||||||
|
|
||||||
const resetPasswordSchema = z.object({
|
const resetPasswordSchema = z.object({
|
||||||
token: z.string().trim().min(20, "重设链接无效"),
|
token: z.string().trim().min(20, "重设链接无效"),
|
||||||
password: z.string().min(6, "新密码至少 6 位"),
|
password: z.string().min(6, "新密码至少 6 位"),
|
||||||
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
|
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function requestContext() {
|
type HeaderList = Awaited<ReturnType<typeof headers>>;
|
||||||
|
|
||||||
|
function getClientIp(headerList: HeaderList) {
|
||||||
|
return headerList.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
|| headerList.get("x-real-ip")?.trim()
|
||||||
|
|| "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestEmailContext(action: string, email: string) {
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
|
const ip = getClientIp(headerList);
|
||||||
|
const [emailLimit, ipLimit] = await Promise.all([
|
||||||
|
rateLimit(`ratelimit:${action}:${email}`, EMAIL_ACTION_LIMIT, EMAIL_ACTION_WINDOW_SECONDS),
|
||||||
|
rateLimit(`ratelimit:${action}:ip:${ip}`, EMAIL_ACTION_IP_LIMIT, EMAIL_ACTION_WINDOW_SECONDS),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!emailLimit.success || !ipLimit.success) {
|
||||||
|
throw new Error("请求过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
|
||||||
return { headers: headerList };
|
return { headers: headerList };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +55,7 @@ async function assertMailAvailable() {
|
|||||||
export async function requestPasswordReset(formData: FormData) {
|
export async function requestPasswordReset(formData: FormData) {
|
||||||
const parsed = emailSchema.parse(Object.fromEntries(formData));
|
const parsed = emailSchema.parse(Object.fromEntries(formData));
|
||||||
const email = normalizeEmailAddress(parsed.email);
|
const email = normalizeEmailAddress(parsed.email);
|
||||||
const { success } = await rateLimit(`ratelimit:password-reset:${email}`, 3, 10 * 60);
|
const context = await requestEmailContext("password-reset", email);
|
||||||
if (!success) {
|
|
||||||
throw new Error("请求过于频繁,请稍后再试");
|
|
||||||
}
|
|
||||||
|
|
||||||
await assertMailAvailable();
|
await assertMailAvailable();
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -48,7 +67,7 @@ export async function requestPasswordReset(formData: FormData) {
|
|||||||
await sendPasswordResetEmail({
|
await sendPasswordResetEmail({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
...(await requestContext()),
|
...context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,10 +75,7 @@ export async function requestPasswordReset(formData: FormData) {
|
|||||||
export async function requestRegistrationVerification(formData: FormData) {
|
export async function requestRegistrationVerification(formData: FormData) {
|
||||||
const parsed = emailSchema.parse(Object.fromEntries(formData));
|
const parsed = emailSchema.parse(Object.fromEntries(formData));
|
||||||
const email = normalizeEmailAddress(parsed.email);
|
const email = normalizeEmailAddress(parsed.email);
|
||||||
const { success } = await rateLimit(`ratelimit:email-verify:${email}`, 3, 10 * 60);
|
const context = await requestEmailContext("email-verify", email);
|
||||||
if (!success) {
|
|
||||||
throw new Error("请求过于频繁,请稍后再试");
|
|
||||||
}
|
|
||||||
|
|
||||||
await assertMailAvailable();
|
await assertMailAvailable();
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -71,7 +87,7 @@ export async function requestRegistrationVerification(formData: FormData) {
|
|||||||
await sendRegistrationVerificationEmail({
|
await sendRegistrationVerificationEmail({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
...(await requestContext()),
|
...context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { headers } from "next/headers";
|
|||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { normalizeEmailAddress, sendEmailChangeConfirmation } from "@/services/email";
|
import { normalizeEmailAddress, sendEmailChangeConfirmation } from "@/services/email";
|
||||||
import { requireAuth } from "@/lib/require-auth";
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
|
||||||
@@ -23,6 +24,28 @@ const emailChangeSchema = z.object({
|
|||||||
email: z.string().trim().email("请输入正确的新邮箱"),
|
email: z.string().trim().email("请输入正确的新邮箱"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const EMAIL_CHANGE_LIMIT = 3;
|
||||||
|
const EMAIL_CHANGE_WINDOW_SECONDS = 10 * 60;
|
||||||
|
|
||||||
|
async function assertEmailChangeRateLimit(userId: string, email: string) {
|
||||||
|
const [userLimit, targetLimit] = await Promise.all([
|
||||||
|
rateLimit(
|
||||||
|
`ratelimit:account-email-change:user:${userId}`,
|
||||||
|
EMAIL_CHANGE_LIMIT,
|
||||||
|
EMAIL_CHANGE_WINDOW_SECONDS,
|
||||||
|
),
|
||||||
|
rateLimit(
|
||||||
|
`ratelimit:account-email-change:email:${email}`,
|
||||||
|
EMAIL_CHANGE_LIMIT,
|
||||||
|
EMAIL_CHANGE_WINDOW_SECONDS,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!userLimit.success || !targetLimit.success) {
|
||||||
|
throw new Error("请求过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function generateUniqueInviteCode(): Promise<string> {
|
async function generateUniqueInviteCode(): Promise<string> {
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 10; i += 1) {
|
||||||
const code = randomBytes(4).toString("hex").toUpperCase();
|
const code = randomBytes(4).toString("hex").toUpperCase();
|
||||||
@@ -71,6 +94,8 @@ export async function requestAccountEmailChange(formData: FormData) {
|
|||||||
throw new Error("这个邮箱已经被其他账户使用");
|
throw new Error("这个邮箱已经被其他账户使用");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertEmailChangeRateLimit(session.user.id, email);
|
||||||
|
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
await sendEmailChangeConfirmation({
|
await sendEmailChangeConfirmation({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user