fix: create users after email verification

This commit is contained in:
JetSprow
2026-04-30 15:14:05 +10:00
parent 85abba9bbf
commit 45e2257e68
5 changed files with 214 additions and 23 deletions

View File

@@ -215,6 +215,24 @@ model EmailToken {
@@index([expiresAt]) @@index([expiresAt])
} }
model PendingRegistration {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
inviteCode String?
invitedById String?
tokenHash String @unique
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([consumedAt, expiresAt])
@@index([expiresAt])
@@index([invitedById])
}
model SubscriptionCategory { model SubscriptionCategory {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -780,10 +798,10 @@ model AppConfig {
subscriptionRiskIpLimitPerHour Int @default(180) subscriptionRiskIpLimitPerHour Int @default(180)
subscriptionRiskTokenLimitPerHour Int @default(60) subscriptionRiskTokenLimitPerHour Int @default(60)
nodeAccessRiskEnabled Boolean @default(true) nodeAccessRiskEnabled Boolean @default(true)
nodeAccessConnectionWarning Int @default(180) nodeAccessConnectionWarning Int @default(180)
nodeAccessConnectionSuspend Int @default(360) nodeAccessConnectionSuspend Int @default(360)
nodeAccessUniqueTargetWarning Int @default(80) nodeAccessUniqueTargetWarning Int @default(80)
nodeAccessUniqueTargetSuspend Int @default(160) nodeAccessUniqueTargetSuspend Int @default(160)
inviteRewardCouponId String? inviteRewardCouponId String?
inviteRewardRate Decimal @default(0) inviteRewardRate Decimal @default(0)
inviteRewardEnabled Boolean @default(false) inviteRewardEnabled Boolean @default(false)

View File

@@ -7,7 +7,15 @@ import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context"; import { getClientIp } from "@/lib/request-context";
import { getAppConfig } from "@/services/app-config"; import { getAppConfig } from "@/services/app-config";
import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email"; import {
consumePasswordResetToken,
isSmtpConfigured,
normalizeEmailAddress,
resendPendingRegistrationVerificationEmail,
sendPasswordResetEmail,
sendRegistrationVerificationEmail,
verifyEmailToken,
} from "@/services/email";
const emailSchema = z.object({ const emailSchema = z.object({
email: z.string().trim().email("请输入正确的邮箱"), email: z.string().trim().email("请输入正确的邮箱"),
@@ -83,7 +91,13 @@ export async function requestRegistrationVerification(formData: FormData) {
email: user.email, email: user.email,
...context, ...context,
}); });
return;
} }
await resendPendingRegistrationVerificationEmail({
email,
...context,
});
} }
export async function resetPasswordByEmail(formData: FormData) { export async function resetPasswordByEmail(formData: FormData) {

View File

@@ -68,7 +68,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
<div className="text-4xl" aria-hidden="true">🎉</div> <div className="text-4xl" aria-hidden="true">🎉</div>
<h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1> <h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
<p className="text-sm leading-6 text-muted-foreground"> <p className="text-sm leading-6 text-muted-foreground">
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"} {requiresEmailVerification ? "请查收邮箱并完成验证,验证后会自动创建账户。" : "账户已创建,请登录。"}
</p> </p>
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}> <Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"} {requiresEmailVerification ? "没有收到?重新发送" : "去登录"}

View File

@@ -6,7 +6,7 @@ import { getAppConfig } from "@/services/app-config";
import { verifyTurnstile } from "@/lib/turnstile"; import { verifyTurnstile } from "@/lib/turnstile";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context"; import { getClientIp } from "@/lib/request-context";
import { isSmtpConfigured, normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email"; import { isSmtpConfigured, normalizeEmailAddress, sendPendingRegistrationVerificationEmail } from "@/services/email";
import { decryptIfEncrypted } from "@/lib/crypto"; import { decryptIfEncrypted } from "@/lib/crypto";
const schema = z.object({ const schema = z.object({
@@ -90,33 +90,37 @@ export async function POST(req: Request) {
} }
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
emailVerifiedAt: config.emailVerificationRequired ? null : new Date(),
status: config.emailVerificationRequired ? "PENDING_EMAIL" : "ACTIVE",
password: hashedPassword,
name: name || null,
invitedById: inviterId,
},
select: { id: true, email: true },
});
if (config.emailVerificationRequired) { if (config.emailVerificationRequired) {
try { try {
await sendRegistrationVerificationEmail({ await sendPendingRegistrationVerificationEmail({
userId: user.id, email,
email: user.email, passwordHash: hashedPassword,
name: name || null,
inviteCode: inviteCode || null,
invitedById: inviterId,
headers: req.headers, headers: req.headers,
requestUrl: req.url, requestUrl: req.url,
}); });
} catch (error) { } catch (error) {
await prisma.user.delete({ where: { id: user.id } }).catch(() => null);
return NextResponse.json( return NextResponse.json(
{ error: error instanceof Error ? error.message : "验证邮件发送失败" }, { error: error instanceof Error ? error.message : "验证邮件发送失败" },
{ status: 500 }, { status: 500 },
); );
} }
} else {
await prisma.$transaction(async (tx) => {
await tx.user.create({
data: {
email,
emailVerifiedAt: new Date(),
status: "ACTIVE",
password: hashedPassword,
name: name || null,
invitedById: inviterId,
},
});
await tx.pendingRegistration.deleteMany({ where: { email } });
});
} }
return NextResponse.json({ ok: true, requiresEmailVerification: config.emailVerificationRequired }); return NextResponse.json({ ok: true, requiresEmailVerification: config.emailVerificationRequired });

View File

@@ -161,6 +161,85 @@ export async function sendRegistrationVerificationEmail(input: {
}); });
} }
export async function sendPendingRegistrationVerificationEmail(input: {
email: string;
passwordHash: string;
name?: string | null;
inviteCode?: string | null;
invitedById?: string | null;
headers?: Headers;
requestUrl?: string;
}) {
const config = await getAppConfig();
const email = normalizeEmailAddress(input.email);
const token = crypto.randomBytes(TOKEN_BYTES).toString("hex");
await prisma.pendingRegistration.upsert({
where: { email },
create: {
email,
passwordHash: input.passwordHash,
name: input.name || null,
inviteCode: input.inviteCode || null,
invitedById: input.invitedById || null,
tokenHash: hashToken(token),
expiresAt: addMinutes(REGISTRATION_TTL_MINUTES),
},
update: {
passwordHash: input.passwordHash,
name: input.name || null,
inviteCode: input.inviteCode || null,
invitedById: input.invitedById || null,
tokenHash: hashToken(token),
expiresAt: addMinutes(REGISTRATION_TTL_MINUTES),
consumedAt: null,
},
});
const url = await buildActionUrl("/verify-email", token, input);
const template = renderRegistrationEmail(config.siteName, url);
await sendMail(config, email, {
subject: `验证你的 ${config.siteName} 邮箱`,
...template,
});
}
export async function resendPendingRegistrationVerificationEmail(input: {
email: string;
headers?: Headers;
requestUrl?: string;
}) {
const config = await getAppConfig();
const email = normalizeEmailAddress(input.email);
const pending = await prisma.pendingRegistration.findUnique({
where: { email },
select: { id: true, consumedAt: true },
});
if (!pending || pending.consumedAt) return false;
const token = crypto.randomBytes(TOKEN_BYTES).toString("hex");
await prisma.pendingRegistration.update({
where: { id: pending.id },
data: {
tokenHash: hashToken(token),
expiresAt: addMinutes(REGISTRATION_TTL_MINUTES),
consumedAt: null,
},
});
const url = await buildActionUrl("/verify-email", token, input);
const template = renderRegistrationEmail(config.siteName, url);
await sendMail(config, email, {
subject: `验证你的 ${config.siteName} 邮箱`,
...template,
});
return true;
}
export async function sendPasswordResetEmail(input: { export async function sendPasswordResetEmail(input: {
userId: string; userId: string;
email: string; email: string;
@@ -237,6 +316,9 @@ export async function consumeEmailToken(token: string, purpose?: EmailPurpose) {
} }
export async function verifyEmailToken(token: string) { export async function verifyEmailToken(token: string) {
const pendingResult = await consumePendingRegistrationToken(token);
if (pendingResult) return pendingResult;
const record = await consumeEmailToken(token); const record = await consumeEmailToken(token);
if (!record) return { ok: false as const, message: "验证链接无效或已过期" }; if (!record) return { ok: false as const, message: "验证链接无效或已过期" };
@@ -280,6 +362,79 @@ export async function verifyEmailToken(token: string) {
return { ok: false as const, message: "这个链接不能用于邮箱验证" }; return { ok: false as const, message: "这个链接不能用于邮箱验证" };
} }
async function consumePendingRegistrationToken(token: string) {
const tokenHash = hashToken(token.trim());
const pending = await prisma.pendingRegistration.findUnique({ where: { tokenHash } });
if (!pending || pending.consumedAt || pending.expiresAt <= new Date()) {
return null;
}
const now = new Date();
return prisma.$transaction(async (tx) => {
const consumed = await tx.pendingRegistration.updateMany({
where: {
id: pending.id,
consumedAt: null,
expiresAt: { gt: now },
},
data: { consumedAt: now },
});
if (consumed.count !== 1) {
return { ok: false as const, message: "验证链接无效或已过期" };
}
const existing = await tx.user.findUnique({
where: { email: pending.email },
select: { id: true },
});
if (existing) {
await tx.pendingRegistration.delete({ where: { id: pending.id } });
return { ok: false as const, message: "这个邮箱已经注册,请直接登录。" };
}
let invitedById = pending.invitedById;
if (invitedById) {
const inviter = await tx.user.findUnique({
where: { id: invitedById },
select: { id: true },
});
invitedById = inviter?.id ?? null;
}
if (!invitedById && pending.inviteCode) {
const inviter = await tx.user.findUnique({
where: { inviteCode: pending.inviteCode },
select: { id: true },
});
invitedById = inviter?.id ?? null;
}
try {
await tx.user.create({
data: {
email: pending.email,
password: pending.passwordHash,
name: pending.name,
invitedById,
emailVerifiedAt: now,
status: "ACTIVE",
},
});
await tx.pendingRegistration.delete({ where: { id: pending.id } });
} catch (error) {
if ((error as { code?: string }).code === "P2002") {
await tx.pendingRegistration.deleteMany({ where: { id: pending.id } });
return { ok: false as const, message: "这个邮箱已经注册,请直接登录。" };
}
throw error;
}
return { ok: true as const, message: "邮箱验证完成,账户已创建,现在可以登录。" };
});
}
export async function consumePasswordResetToken(token: string) { export async function consumePasswordResetToken(token: string) {
const record = await consumeEmailToken(token, "PASSWORD_RESET"); const record = await consumeEmailToken(token, "PASSWORD_RESET");
if (!record?.userId) { if (!record?.userId) {