diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 2cf8c4495..47c3ec98a 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -308,6 +308,11 @@ type AuthSettings = { isDingTalkEnabled: boolean; isGoogleEnabled: boolean; isRegistrationEnabled?: boolean; + isEmailVerificationEnabled?: boolean; + emailVerificationExpiresMinutes?: number; + resendApiKey?: string | null; + resendFromEmail?: string | null; + isEmailServiceConfigured?: boolean; dingTalkOrganizations: DingTalkOrganization[]; }; @@ -1027,6 +1032,11 @@ export function AdminSettingsClient() { isDingTalkEnabled: false, isGoogleEnabled: false, isRegistrationEnabled: true, + isEmailVerificationEnabled: false, + emailVerificationExpiresMinutes: 10, + resendApiKey: "", + resendFromEmail: "", + isEmailServiceConfigured: false, dingTalkOrganizations: [], }); const [isGoogleConfigured, setIsGoogleConfigured] = useState(false); @@ -1060,6 +1070,13 @@ export function AdminSettingsClient() { isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false, isGoogleEnabled: authSettingsData.isGoogleEnabled || false, isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true, + isEmailVerificationEnabled: + authSettingsData.isEmailVerificationEnabled === true, + emailVerificationExpiresMinutes: + Number(authSettingsData.emailVerificationExpiresMinutes) || 10, + resendApiKey: authSettingsData.resendApiKey || "", + resendFromEmail: authSettingsData.resendFromEmail || "", + isEmailServiceConfigured: authSettingsData.isEmailServiceConfigured === true, dingTalkOrganizations: normalizedOrgs, }); setIsGoogleConfigured(Boolean(authSettingsData.isGoogleConfigured)); @@ -5531,6 +5548,84 @@ export function AdminSettingsClient() { onCheckedChange={(checked) => setAuthSettings({ ...authSettings, isRegistrationEnabled: checked })} /> +
+ 开启后,用户注册时必须先收取邮箱验证码。 +
++ 当前状态: + {Boolean( + String(authSettings.resendApiKey || "").trim() && + String(authSettings.resendFromEmail || "").trim() + ) + ? " 已配置" + : " 未配置"} +
+
已经有账号?
diff --git a/app/api/auth-settings/route.ts b/app/api/auth-settings/route.ts
index 872a3a5f8..8d211dc04 100644
--- a/app/api/auth-settings/route.ts
+++ b/app/api/auth-settings/route.ts
@@ -34,6 +34,11 @@ export async function GET() {
authUrl: process.env.AUTH_URL || "",
isGoogleConfigured,
isRegistrationEnabled: true,
+ isEmailVerificationEnabled: false,
+ emailVerificationExpiresMinutes: 10,
+ resendApiKey: "",
+ resendFromEmail: "",
+ isEmailServiceConfigured: false,
});
}
@@ -45,6 +50,13 @@ export async function GET() {
authUrl: resolvedAuthUrl,
isGoogleConfigured,
dingTalkOrganizations: settings.dingTalkOrganizations ?? [],
+ isEmailVerificationEnabled: settings.isEmailVerificationEnabled,
+ emailVerificationExpiresMinutes: settings.emailVerificationExpiresMinutes,
+ resendApiKey: settings.resendApiKey || "",
+ resendFromEmail: settings.resendFromEmail || "",
+ isEmailServiceConfigured: Boolean(
+ (settings.resendApiKey || "").trim() && (settings.resendFromEmail || "").trim()
+ ),
});
}
@@ -61,6 +73,11 @@ export async function GET() {
authUrl: resolvedAuthUrl,
isGoogleConfigured,
isRegistrationEnabled: settings.isRegistrationEnabled,
+ isEmailVerificationEnabled: settings.isEmailVerificationEnabled,
+ emailVerificationExpiresMinutes: settings.emailVerificationExpiresMinutes,
+ isEmailServiceConfigured: Boolean(
+ (settings.resendApiKey || "").trim() && (settings.resendFromEmail || "").trim()
+ ),
organizations,
// 不返回 dingTalkAppSecret 以及各组织的密钥
});
@@ -89,6 +106,10 @@ const updateSchema = z.object({
googleClientSecret: z.string().optional(),
authUrl: z.string().optional(),
isRegistrationEnabled: z.boolean().optional(),
+ isEmailVerificationEnabled: z.boolean().optional(),
+ emailVerificationExpiresMinutes: z.number().int().min(3).max(30).optional(),
+ resendApiKey: z.string().optional(),
+ resendFromEmail: z.string().optional(),
});
// PUT /api/auth-settings - 更新认证配置(仅管理员)
@@ -124,6 +145,12 @@ export async function PUT(request: NextRequest) {
const resolvedGoogleClientSecret =
data.googleClientSecret ?? existing?.googleClientSecret ?? "";
const resolvedAuthUrl = data.authUrl ?? existing?.authUrl ?? "";
+ const resolvedResendApiKey = String(
+ data.resendApiKey ?? existing?.resendApiKey ?? ""
+ ).trim();
+ const resolvedResendFromEmail = String(
+ data.resendFromEmail ?? existing?.resendFromEmail ?? ""
+ ).trim();
if (existing) {
// 更新现有配置
@@ -141,6 +168,13 @@ export async function PUT(request: NextRequest) {
isGoogleEnabled: data.isGoogleEnabled ?? existing.isGoogleEnabled,
isRegistrationEnabled:
data.isRegistrationEnabled ?? existing.isRegistrationEnabled,
+ isEmailVerificationEnabled:
+ data.isEmailVerificationEnabled ?? existing.isEmailVerificationEnabled,
+ emailVerificationExpiresMinutes:
+ data.emailVerificationExpiresMinutes ??
+ existing.emailVerificationExpiresMinutes,
+ resendApiKey: resolvedResendApiKey,
+ resendFromEmail: resolvedResendFromEmail,
updatedAt: new Date(),
updatedBy: session.user.id,
})
@@ -163,6 +197,11 @@ export async function PUT(request: NextRequest) {
isDingTalkEnabled: data.isDingTalkEnabled || false,
isGoogleEnabled: data.isGoogleEnabled || false,
isRegistrationEnabled: data.isRegistrationEnabled ?? true,
+ isEmailVerificationEnabled: data.isEmailVerificationEnabled ?? false,
+ emailVerificationExpiresMinutes:
+ data.emailVerificationExpiresMinutes ?? 10,
+ resendApiKey: resolvedResendApiKey,
+ resendFromEmail: resolvedResendFromEmail,
updatedBy: session.user.id,
})
.returning();
diff --git a/app/api/auth/email/send-code/route.ts b/app/api/auth/email/send-code/route.ts
new file mode 100644
index 000000000..2af232c15
--- /dev/null
+++ b/app/api/auth/email/send-code/route.ts
@@ -0,0 +1,189 @@
+import { and, desc, eq, gt } from "drizzle-orm";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import {
+ createEmailVerificationCode,
+ EMAIL_VERIFICATION_PURPOSE_REGISTER,
+ hashEmailVerificationCode,
+ resolveResendConfig,
+ sendVerificationCodeByResend,
+} from "@/lib/auth/email-verification";
+import { db } from "@/lib/db";
+import { emailVerificationCode, siteSettings, user } from "@/lib/db/schema";
+import { getAuthSettings } from "@/lib/db/queries";
+import { checkRateLimit, getClientIdentifier, rateLimiters } from "@/lib/security/rate-limiter";
+
+const payloadSchema = z.object({
+ email: z.string().email(),
+});
+
+const SEND_CODE_COOLDOWN_SECONDS = 60;
+
+const emailCooldownMap = new Map
你正在注册 ${params.siteName},验证码如下:
+验证码 ${params.expiresInMinutes} 分钟内有效,请勿泄露给他人。
+