feat: add email verification with resend auth settings
This commit is contained in:
parent
2c0a5379e7
commit
aa1ec8bd43
@ -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 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between md:col-span-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label>邮箱验证码注册</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
开启后,用户注册时必须先收取邮箱验证码。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={authSettings.isEmailVerificationEnabled === true}
|
||||
onCheckedChange={(checked) =>
|
||||
setAuthSettings({
|
||||
...authSettings,
|
||||
isEmailVerificationEnabled: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="emailVerificationExpiresMinutes">
|
||||
验证码有效期(分钟)
|
||||
</Label>
|
||||
<Input
|
||||
id="emailVerificationExpiresMinutes"
|
||||
type="number"
|
||||
min={3}
|
||||
max={30}
|
||||
value={authSettings.emailVerificationExpiresMinutes ?? 10}
|
||||
onChange={(event) =>
|
||||
setAuthSettings({
|
||||
...authSettings,
|
||||
emailVerificationExpiresMinutes: Math.min(
|
||||
Math.max(Number(event.target.value) || 10, 3),
|
||||
30
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="resendFromEmail">发件邮箱(Resend)</Label>
|
||||
<Input
|
||||
id="resendFromEmail"
|
||||
value={authSettings.resendFromEmail || ""}
|
||||
onChange={(event) =>
|
||||
setAuthSettings({
|
||||
...authSettings,
|
||||
resendFromEmail: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="noreply@your-domain.ai"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="resendApiKey">Resend API Key</Label>
|
||||
<Input
|
||||
id="resendApiKey"
|
||||
type="password"
|
||||
value={authSettings.resendApiKey || ""}
|
||||
onChange={(event) =>
|
||||
setAuthSettings({
|
||||
...authSettings,
|
||||
resendApiKey: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="re_xxxxxxxxxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground md:col-span-2">
|
||||
当前状态:
|
||||
{Boolean(
|
||||
String(authSettings.resendApiKey || "").trim() &&
|
||||
String(authSettings.resendFromEmail || "").trim()
|
||||
)
|
||||
? " 已配置"
|
||||
: " 未配置"}
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">钉钉登录</h3>
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { createUser, getUser, getAuthSettings } from "@/lib/db/queries";
|
||||
import {
|
||||
EMAIL_VERIFICATION_PURPOSE_REGISTER,
|
||||
hashEmailVerificationCode,
|
||||
} from "@/lib/auth/email-verification";
|
||||
import { db } from "@/lib/db";
|
||||
import { emailVerificationCode } from "@/lib/db/schema";
|
||||
|
||||
import { signIn } from "./auth";
|
||||
|
||||
@ -11,6 +18,18 @@ const authFormSchema = z.object({
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
const registerFormSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
confirmPassword: z.string().min(6),
|
||||
verificationCode: z.string().optional(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Password mismatch",
|
||||
});
|
||||
|
||||
export type LoginActionState = {
|
||||
status: "idle" | "in_progress" | "success" | "failed" | "invalid_data";
|
||||
};
|
||||
@ -69,6 +88,10 @@ export type RegisterActionState = {
|
||||
| "success"
|
||||
| "failed"
|
||||
| "user_exists"
|
||||
| "password_mismatch"
|
||||
| "verification_code_required"
|
||||
| "verification_code_invalid"
|
||||
| "verification_code_expired"
|
||||
| "invalid_data";
|
||||
};
|
||||
|
||||
@ -83,11 +106,64 @@ export const register = async (
|
||||
return { status: "failed" };
|
||||
}
|
||||
|
||||
const validatedData = authFormSchema.parse({
|
||||
const validatedData = registerFormSchema.parse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
verificationCode:
|
||||
typeof formData.get("verificationCode") === "string"
|
||||
? String(formData.get("verificationCode"))
|
||||
: undefined,
|
||||
});
|
||||
const normalizedEmail = validatedData.email.trim().toLowerCase();
|
||||
const verificationCodeInput = String(
|
||||
validatedData.verificationCode || ""
|
||||
).trim();
|
||||
|
||||
if (settings.isEmailVerificationEnabled === true) {
|
||||
if (!verificationCodeInput) {
|
||||
return { status: "verification_code_required" };
|
||||
}
|
||||
if (!/^\d{6}$/.test(verificationCodeInput)) {
|
||||
return { status: "verification_code_invalid" };
|
||||
}
|
||||
|
||||
const [latestCode] = await db
|
||||
.select()
|
||||
.from(emailVerificationCode)
|
||||
.where(
|
||||
and(
|
||||
eq(emailVerificationCode.email, normalizedEmail),
|
||||
eq(
|
||||
emailVerificationCode.purpose,
|
||||
EMAIL_VERIFICATION_PURPOSE_REGISTER
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(emailVerificationCode.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (!latestCode) {
|
||||
return { status: "verification_code_invalid" };
|
||||
}
|
||||
|
||||
if (new Date(latestCode.expiresAt) <= new Date()) {
|
||||
await db
|
||||
.delete(emailVerificationCode)
|
||||
.where(eq(emailVerificationCode.id, latestCode.id));
|
||||
return { status: "verification_code_expired" };
|
||||
}
|
||||
|
||||
const expectedHash = hashEmailVerificationCode({
|
||||
email: normalizedEmail,
|
||||
purpose: EMAIL_VERIFICATION_PURPOSE_REGISTER,
|
||||
code: verificationCodeInput,
|
||||
});
|
||||
|
||||
if (latestCode.codeHash !== expectedHash) {
|
||||
return { status: "verification_code_invalid" };
|
||||
}
|
||||
}
|
||||
|
||||
const [user] = await getUser(normalizedEmail);
|
||||
|
||||
@ -101,9 +177,24 @@ export const register = async (
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
await db
|
||||
.delete(emailVerificationCode)
|
||||
.where(
|
||||
and(
|
||||
eq(emailVerificationCode.email, normalizedEmail),
|
||||
eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER)
|
||||
)
|
||||
);
|
||||
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const passwordMismatch = error.issues.some(
|
||||
(issue) => issue.path?.[0] === "confirmPassword"
|
||||
);
|
||||
if (passwordMismatch) {
|
||||
return { status: "password_mismatch" };
|
||||
}
|
||||
return { status: "invalid_data" };
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,9 @@ import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { toast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { type RegisterActionState, register } from "../actions";
|
||||
|
||||
export default function Page() {
|
||||
@ -15,6 +18,10 @@ export default function Page() {
|
||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||
const [siteName, setSiteName] = useState("AI Chatbot");
|
||||
const [siteLogo, setSiteLogo] = useState<string | null>(null);
|
||||
const [isEmailVerificationEnabled, setIsEmailVerificationEnabled] = useState(false);
|
||||
const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10);
|
||||
const [isSendingCode, setIsSendingCode] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const redirectTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -26,6 +33,9 @@ export default function Page() {
|
||||
toast({ type: "error", description: "当前系统未开放注册" });
|
||||
router.push("/login");
|
||||
}
|
||||
setIsEmailVerificationEnabled(data.isEmailVerificationEnabled === true);
|
||||
const minutes = Number(data.emailVerificationExpiresMinutes) || 10;
|
||||
setEmailVerificationExpiresMinutes(Math.min(Math.max(minutes, 3), 30));
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
@ -39,6 +49,16 @@ export default function Page() {
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeCooldown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setCodeCooldown((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [codeCooldown]);
|
||||
|
||||
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
||||
register,
|
||||
{
|
||||
@ -56,6 +76,18 @@ export default function Page() {
|
||||
if (state.status === "user_exists") {
|
||||
setIsSuccessful(false);
|
||||
toast({ type: "error", description: "账号已存在" });
|
||||
} else if (state.status === "password_mismatch") {
|
||||
setIsSuccessful(false);
|
||||
toast({ type: "error", description: "两次输入的密码不一致" });
|
||||
} else if (state.status === "verification_code_required") {
|
||||
setIsSuccessful(false);
|
||||
toast({ type: "error", description: "请先输入邮箱验证码" });
|
||||
} else if (state.status === "verification_code_invalid") {
|
||||
setIsSuccessful(false);
|
||||
toast({ type: "error", description: "邮箱验证码不正确" });
|
||||
} else if (state.status === "verification_code_expired") {
|
||||
setIsSuccessful(false);
|
||||
toast({ type: "error", description: "邮箱验证码已过期,请重新发送" });
|
||||
} else if (state.status === "failed") {
|
||||
setIsSuccessful(false);
|
||||
toast({ type: "error", description: "创建账号失败" });
|
||||
@ -87,6 +119,44 @@ export default function Page() {
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
toast({ type: "error", description: "请先输入邮箱地址" });
|
||||
return;
|
||||
}
|
||||
if (codeCooldown > 0 || isSendingCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingCode(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/email/send-code", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: normalizedEmail }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || "验证码发送失败");
|
||||
}
|
||||
|
||||
const cooldownSeconds = Number(data?.cooldownSeconds) || 60;
|
||||
setCodeCooldown(cooldownSeconds);
|
||||
toast({
|
||||
type: "success",
|
||||
description: `验证码已发送,请在 ${emailVerificationExpiresMinutes} 分钟内完成验证`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
type: "error",
|
||||
description: error instanceof Error ? error.message : "验证码发送失败",
|
||||
});
|
||||
} finally {
|
||||
setIsSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
|
||||
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
|
||||
@ -99,7 +169,45 @@ export default function Page() {
|
||||
使用邮箱与密码创建新账号
|
||||
</p>
|
||||
</div>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<AuthForm
|
||||
action={handleSubmit}
|
||||
defaultEmail={email}
|
||||
onEmailChange={setEmail}
|
||||
showConfirmPassword
|
||||
>
|
||||
{isEmailVerificationEnabled ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||
htmlFor="verificationCode"
|
||||
>
|
||||
邮箱验证码
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="bg-muted text-md md:text-sm"
|
||||
id="verificationCode"
|
||||
name="verificationCode"
|
||||
placeholder="请输入 6 位验证码"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSendCode}
|
||||
disabled={isSendingCode || codeCooldown > 0}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isSendingCode
|
||||
? "发送中..."
|
||||
: codeCooldown > 0
|
||||
? `${codeCooldown}s`
|
||||
: "发送验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SubmitButton isSuccessful={isSuccessful}>注册</SubmitButton>
|
||||
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
|
||||
已经有账号?
|
||||
|
||||
@ -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();
|
||||
|
||||
189
app/api/auth/email/send-code/route.ts
Normal file
189
app/api/auth/email/send-code/route.ts
Normal file
@ -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<string, number>();
|
||||
|
||||
function getCooldownSeconds(email: string): number {
|
||||
const expiredAt = emailCooldownMap.get(email.toLowerCase());
|
||||
if (!expiredAt) {
|
||||
return 0;
|
||||
}
|
||||
const remaining = Math.ceil((expiredAt - Date.now()) / 1000);
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
function setCooldown(email: string): void {
|
||||
emailCooldownMap.set(
|
||||
email.toLowerCase(),
|
||||
Date.now() + SEND_CODE_COOLDOWN_SECONDS * 1000
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const rateLimit = checkRateLimit(request, rateLimiters.auth);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json({ error: "请求过于频繁,请稍后重试" }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = payloadSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = parsed.data.email.trim().toLowerCase();
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
const localCooldown = getCooldownSeconds(`${clientId}:${email}`);
|
||||
if (localCooldown > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `验证码发送过于频繁,请 ${localCooldown}s 后重试` },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const authSettings = await getAuthSettings();
|
||||
if (authSettings.isRegistrationEnabled === false) {
|
||||
return NextResponse.json({ error: "当前系统未开放注册" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (authSettings.isEmailVerificationEnabled !== true) {
|
||||
return NextResponse.json({ error: "邮箱验证未启用" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resendConfig = resolveResendConfig({
|
||||
apiKey: authSettings.resendApiKey,
|
||||
fromEmail: authSettings.resendFromEmail,
|
||||
});
|
||||
|
||||
if (!resendConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: "邮箱服务未配置,请在认证管理中配置 Resend API Key 和发件邮箱" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const [existingUser] = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: "该邮箱已注册" }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [latestCode] = await db
|
||||
.select({ createdAt: emailVerificationCode.createdAt })
|
||||
.from(emailVerificationCode)
|
||||
.where(
|
||||
and(
|
||||
eq(emailVerificationCode.email, email),
|
||||
eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER),
|
||||
gt(emailVerificationCode.expiresAt, now)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(emailVerificationCode.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (latestCode?.createdAt) {
|
||||
const createdAtMs = new Date(latestCode.createdAt).getTime();
|
||||
const waitSeconds = Math.ceil((createdAtMs + SEND_CODE_COOLDOWN_SECONDS * 1000 - Date.now()) / 1000);
|
||||
if (waitSeconds > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `验证码发送过于频繁,请 ${waitSeconds}s 后重试` },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const expiresInMinutes = Math.min(
|
||||
Math.max(Number(authSettings.emailVerificationExpiresMinutes) || 10, 3),
|
||||
30
|
||||
);
|
||||
|
||||
const [siteSettingRecord] = await db
|
||||
.select({ siteName: siteSettings.siteName })
|
||||
.from(siteSettings)
|
||||
.limit(1);
|
||||
|
||||
const code = createEmailVerificationCode();
|
||||
const codeHash = hashEmailVerificationCode({
|
||||
email,
|
||||
purpose: EMAIL_VERIFICATION_PURPOSE_REGISTER,
|
||||
code,
|
||||
});
|
||||
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
|
||||
|
||||
await db
|
||||
.delete(emailVerificationCode)
|
||||
.where(
|
||||
and(
|
||||
eq(emailVerificationCode.email, email),
|
||||
eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER)
|
||||
)
|
||||
);
|
||||
|
||||
await db.insert(emailVerificationCode).values({
|
||||
email,
|
||||
purpose: EMAIL_VERIFICATION_PURPOSE_REGISTER,
|
||||
codeHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerificationCodeByResend({
|
||||
to: email,
|
||||
code,
|
||||
siteName: siteSettingRecord?.siteName || "AI Chatbot",
|
||||
expiresInMinutes,
|
||||
config: resendConfig,
|
||||
});
|
||||
} catch (sendError) {
|
||||
await db
|
||||
.delete(emailVerificationCode)
|
||||
.where(
|
||||
and(
|
||||
eq(emailVerificationCode.email, email),
|
||||
eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER)
|
||||
)
|
||||
);
|
||||
|
||||
console.error("[Email Verification] Send code failed:", sendError);
|
||||
return NextResponse.json({ error: "验证码发送失败,请稍后重试" }, { status: 500 });
|
||||
}
|
||||
|
||||
setCooldown(`${clientId}:${email}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "验证码已发送",
|
||||
cooldownSeconds: SEND_CODE_COOLDOWN_SECONDS,
|
||||
expiresInMinutes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Email Verification] Send code error:", error);
|
||||
return NextResponse.json({ error: "发送验证码失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,8 @@ export function AuthForm({
|
||||
children,
|
||||
defaultEmail = "",
|
||||
className = "",
|
||||
showConfirmPassword = false,
|
||||
onEmailChange,
|
||||
}: {
|
||||
action: NonNullable<
|
||||
string | ((formData: FormData) => void | Promise<void>) | undefined
|
||||
@ -15,6 +17,8 @@ export function AuthForm({
|
||||
children: React.ReactNode;
|
||||
defaultEmail?: string;
|
||||
className?: string;
|
||||
showConfirmPassword?: boolean;
|
||||
onEmailChange?: (email: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Form action={action} className={`flex flex-col gap-4 px-4 sm:px-16 ${className}`}>
|
||||
@ -33,6 +37,7 @@ export function AuthForm({
|
||||
defaultValue={defaultEmail}
|
||||
id="email"
|
||||
name="email"
|
||||
onChange={(event) => onEmailChange?.(event.target.value)}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
type="email"
|
||||
@ -56,6 +61,25 @@ export function AuthForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showConfirmPassword ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||
htmlFor="confirmPassword"
|
||||
>
|
||||
确认密码
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
className="bg-muted text-md md:text-sm"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
</Form>
|
||||
);
|
||||
|
||||
90
lib/auth/email-verification.ts
Normal file
90
lib/auth/email-verification.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const EMAIL_VERIFICATION_PURPOSE_REGISTER = "register";
|
||||
|
||||
export type ResendConfig = {
|
||||
apiKey: string;
|
||||
fromEmail: string;
|
||||
};
|
||||
|
||||
function getVerificationSecret(): string {
|
||||
return (
|
||||
process.env.EMAIL_VERIFICATION_SECRET ||
|
||||
process.env.AUTH_SECRET ||
|
||||
"email-verification-secret"
|
||||
);
|
||||
}
|
||||
|
||||
export function hashEmailVerificationCode(params: {
|
||||
email: string;
|
||||
purpose: string;
|
||||
code: string;
|
||||
}): string {
|
||||
const payload = `${params.email.toLowerCase()}:${params.purpose}:${params.code}:${getVerificationSecret()}`;
|
||||
return crypto.createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
export function createEmailVerificationCode(): string {
|
||||
return String(crypto.randomInt(0, 1000000)).padStart(6, "0");
|
||||
}
|
||||
|
||||
export function isResendConfigured(): boolean {
|
||||
return Boolean(process.env.RESEND_API_KEY && process.env.RESEND_FROM_EMAIL);
|
||||
}
|
||||
|
||||
export function resolveResendConfig(config?: {
|
||||
apiKey?: string | null;
|
||||
fromEmail?: string | null;
|
||||
}): ResendConfig | null {
|
||||
const apiKey = String(config?.apiKey || process.env.RESEND_API_KEY || "").trim();
|
||||
const fromEmail = String(config?.fromEmail || process.env.RESEND_FROM_EMAIL || "").trim();
|
||||
if (!apiKey || !fromEmail) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey, fromEmail };
|
||||
}
|
||||
|
||||
export async function sendVerificationCodeByResend(params: {
|
||||
to: string;
|
||||
code: string;
|
||||
siteName: string;
|
||||
expiresInMinutes: number;
|
||||
config?: ResendConfig;
|
||||
}): Promise<void> {
|
||||
const resolvedConfig = params.config || resolveResendConfig();
|
||||
if (!resolvedConfig) {
|
||||
throw new Error("Resend is not configured");
|
||||
}
|
||||
|
||||
const subject = `【${params.siteName}】邮箱验证码`;
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; color: #111;">
|
||||
<h2 style="margin: 0 0 12px; font-size: 18px;">邮箱验证码</h2>
|
||||
<p style="margin: 0 0 12px; font-size: 14px;">你正在注册 ${params.siteName},验证码如下:</p>
|
||||
<div style="font-size: 28px; font-weight: 700; letter-spacing: 6px; margin: 10px 0 16px;">${params.code}</div>
|
||||
<p style="margin: 0; font-size: 13px; color: #666;">验证码 ${params.expiresInMinutes} 分钟内有效,请勿泄露给他人。</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `你正在注册 ${params.siteName},验证码:${params.code}。验证码 ${params.expiresInMinutes} 分钟内有效。`;
|
||||
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${resolvedConfig.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: resolvedConfig.fromEmail,
|
||||
to: params.to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(`Resend request failed: ${response.status} ${detail}`);
|
||||
}
|
||||
}
|
||||
20
lib/db/migrations/0043_add_email_verification.sql
Normal file
20
lib/db/migrations/0043_add_email_verification.sql
Normal file
@ -0,0 +1,20 @@
|
||||
ALTER TABLE "AuthSettings"
|
||||
ADD COLUMN IF NOT EXISTS "isEmailVerificationEnabled" boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "AuthSettings"
|
||||
ADD COLUMN IF NOT EXISTS "emailVerificationExpiresMinutes" integer NOT NULL DEFAULT 10;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "EmailVerificationCode" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" varchar(64) NOT NULL,
|
||||
"purpose" varchar(32) NOT NULL DEFAULT 'register',
|
||||
"codeHash" varchar(128) NOT NULL,
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "EmailVerificationCode_email_purpose_createdAt_idx"
|
||||
ON "EmailVerificationCode" ("email", "purpose", "createdAt");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "EmailVerificationCode_expiresAt_idx"
|
||||
ON "EmailVerificationCode" ("expiresAt");
|
||||
5
lib/db/migrations/0044_add_resend_config.sql
Normal file
5
lib/db/migrations/0044_add_resend_config.sql
Normal file
@ -0,0 +1,5 @@
|
||||
ALTER TABLE "AuthSettings"
|
||||
ADD COLUMN IF NOT EXISTS "resendApiKey" text;
|
||||
|
||||
ALTER TABLE "AuthSettings"
|
||||
ADD COLUMN IF NOT EXISTS "resendFromEmail" text;
|
||||
@ -204,6 +204,20 @@
|
||||
"when": 1772280000000,
|
||||
"tag": "0042_add_realtime_canvas_toggle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1771820533320,
|
||||
"tag": "0043_add_email_verification",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1771821011606,
|
||||
"tag": "0044_add_resend_config",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1982,6 +1982,10 @@ export async function getAuthSettings() {
|
||||
isDingTalkEnabled: false,
|
||||
isGoogleEnabled: false,
|
||||
isRegistrationEnabled: true,
|
||||
isEmailVerificationEnabled: false,
|
||||
emailVerificationExpiresMinutes: 10,
|
||||
resendApiKey: "",
|
||||
resendFromEmail: "",
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
@ -1994,6 +1998,10 @@ export async function getAuthSettings() {
|
||||
isDingTalkEnabled: false,
|
||||
isGoogleEnabled: false,
|
||||
isRegistrationEnabled: true,
|
||||
isEmailVerificationEnabled: false,
|
||||
emailVerificationExpiresMinutes: 10,
|
||||
resendApiKey: "",
|
||||
resendFromEmail: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -416,6 +416,14 @@ export const authSettings = pgTable("AuthSettings", {
|
||||
isDingTalkEnabled: boolean("isDingTalkEnabled").notNull().default(false),
|
||||
isGoogleEnabled: boolean("isGoogleEnabled").notNull().default(false),
|
||||
isRegistrationEnabled: boolean("isRegistrationEnabled").notNull().default(true),
|
||||
isEmailVerificationEnabled: boolean("isEmailVerificationEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
emailVerificationExpiresMinutes: integer("emailVerificationExpiresMinutes")
|
||||
.notNull()
|
||||
.default(10),
|
||||
resendApiKey: text("resendApiKey"),
|
||||
resendFromEmail: text("resendFromEmail"),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
updatedBy: uuid("updatedBy").references(() => user.id),
|
||||
});
|
||||
@ -433,6 +441,17 @@ export const loginToken = pgTable("LoginToken", {
|
||||
|
||||
export type LoginToken = InferSelectModel<typeof loginToken>;
|
||||
|
||||
export const emailVerificationCode = pgTable("EmailVerificationCode", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
email: varchar("email", { length: 64 }).notNull(),
|
||||
purpose: varchar("purpose", { length: 32 }).notNull().default("register"),
|
||||
codeHash: varchar("codeHash", { length: 128 }).notNull(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type EmailVerificationCode = InferSelectModel<typeof emailVerificationCode>;
|
||||
|
||||
// 生成图片记录表
|
||||
export const generatedImage = pgTable("GeneratedImage", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user