import { betterAuth } from "better-auth" import { prismaAdapter } from "better-auth/adapters/prisma" import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api" import { organization, emailOTP } from "better-auth/plugins" import { nextCookies } from "better-auth/next-js" import { bioSchema, displayNameSchema, emailRateLimitKey, isEmailOtpSendPath, isUrlWithinPrefix, } from "./auth-security" import { prisma } from "./db" import { sendOtpEmail } from "./email" import { inviteRejectionMessage, inviteRequired, verifyInviteCode } from "./invite" import { redisSecondaryStorage } from "./redis" import { publicUrl } from "./storage/url" import { provisionPersonalUserWithRollback, type ProvisioningDatabase, } from "./user-provisioning" function otpLabel(type: string) { return type === "sign-in" ? "登录" : type === "email-verification" ? "邮箱验证" : "重置密码" } // 仅在配置了 client id 时才启用对应社交登录(P2 填真实值) const socialProviders: Record = {} if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { socialProviders.google = { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, } } if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { socialProviders.github = { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, } } function invalidProfileField(message: string) { return APIError.fromStatus("BAD_REQUEST", { message }) } /** 请求里带的邀请码:优先请求体 inviteCode,其次 x-invite-code 头(前端走的是头) */ type InviteCarrier = { body?: unknown; headers?: Headers | null } | null | undefined function inviteCodeFrom(context: InviteCarrier) { const body = context?.body if (body && typeof body === "object" && "inviteCode" in body) { const value = (body as Record).inviteCode if (typeof value === "string") return value } return context?.headers?.get("x-invite-code") ?? undefined } /** * 邀请码门禁:请求必须带一个有效邀请码,否则不许建号。 * 生成能力(Evolink)是花钱的,所以线上不接受自助注册。详见 lib/invite.ts。 */ function assertInvited(context: InviteCarrier) { if (verifyInviteCode(inviteCodeFrom(context))) return throw APIError.fromStatus("FORBIDDEN", { message: inviteRejectionMessage() }) } function normalizedName(value: unknown) { const result = displayNameSchema.safeParse(value) if (!result.success) throw invalidProfileField(result.error.issues[0]?.message ?? "昵称格式不正确") return result.data } function normalizedBio(value: unknown) { const result = bioSchema.safeParse(value) if (!result.success) throw invalidProfileField(result.error.issues[0]?.message ?? "简介格式不正确") return result.data } export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql" }), // Redis 作为二级存储:限流计数、会话缓存都走 Redis,多实例安全 secondaryStorage: redisSecondaryStorage, // 服务端限流(真正的防爆破,强制开启,不只靠前端倒计时) rateLimit: { enabled: true, window: 60, max: 60, // 每 IP 每 60s 默认上限 customRules: { "/email-otp/send-verification-otp": { window: 60, max: 1 }, // 发码:60s 仅 1 次 "/sign-in/email-otp": { window: 60, max: 5 }, // 验证码登录尝试 "/email-otp/verify-email": { window: 60, max: 5 }, // 邮箱验证尝试 "/sign-up/email": { window: 300, max: 5 }, // 5 分钟内最多注册 5 次 "/sign-in/email": { window: 300, max: 10 }, // 5 分钟内最多密码登录 10 次 "/change-password": { window: 300, max: 5 }, // 已登录用户修改密码尝试 }, }, emailAndPassword: { enabled: true, // 注册必须通过邮箱验证码:未验证邮箱不能登录(配合 emailOTP sendVerificationOnSignUp) requireEmailVerification: true, revokeSessionsOnPasswordReset: true, }, socialProviders, // 多 provider 互绑:同一已验证邮箱自动合并到同一 user account: { accountLinking: { enabled: true, trustedProviders: ["google", "github"], }, }, user: { additionalFields: { handle: { type: "string", required: false, input: false }, bio: { type: "string", required: false, validator: { input: bioSchema }, }, }, }, // 注册即自动建个人组织;事务和 upsert 保证重试时不会留下半成品。 databaseHooks: { user: { create: { before: async (user, context) => { // 建号的唯一咽喉:不管走哪条路(密码注册 / 邮箱验证码 / 社交登录), // 都必须带有效邀请码。以后哪天配上 Google/GitHub 登录,也不会顺手把注册放开。 if (inviteRequired()) assertInvited(context) return { data: { ...user, name: normalizedName(user.name) } } }, after: async (user) => { await provisionPersonalUserWithRollback( user, prisma as unknown as ProvisioningDatabase, ) }, }, update: { before: async (user) => ({ data: { ...user, ...(user.name !== undefined && { name: normalizedName(user.name) }), ...(user.bio !== undefined && { bio: normalizedBio(user.bio) }), }, }), }, }, }, hooks: { before: createAuthMiddleware(async (context) => { // 密码注册:先看邀请码,再谈算密码哈希和发验证码邮件(省钱也省得被刷) if (context.path === "/sign-up/email" && inviteRequired()) { assertInvited(context) } if (isEmailOtpSendPath(context.path)) { const email = context.body && typeof context.body === "object" && "email" in context.body ? context.body.email : undefined if (typeof email === "string") { const count = await redisSecondaryStorage.increment( emailRateLimitKey(email), 60, ) if (count > 1) { throw APIError.fromStatus("TOO_MANY_REQUESTS", { message: "验证码发送过于频繁,请 60 秒后再试", }) } } } if (context.path !== "/update-user") return if (!context.body || typeof context.body !== "object") { throw invalidProfileField("资料格式不正确") } const body = context.body as Record const allowedFields = new Set(["name", "image", "bio"]) if (Object.keys(body).some((field) => !allowedFields.has(field))) { throw invalidProfileField("包含不允许修改的资料字段") } if (body.name !== undefined) body.name = normalizedName(body.name) if (body.bio !== undefined) body.bio = normalizedBio(body.bio) if (body.image !== undefined && body.image !== null) { const session = await getSessionFromCtx(context) if (!session?.user) { throw APIError.fromStatus("UNAUTHORIZED", { message: "请先登录" }) } const avatarPrefix = publicUrl(`avatars/${session.user.id}/`) if ( typeof body.image !== "string" || !isUrlWithinPrefix(body.image, avatarPrefix) ) { throw invalidProfileField("头像地址不属于当前用户") } } }), }, plugins: [ emailOTP({ // 密码注册后也发一封验证码邮件 sendVerificationOnSignUp: true, // 验证码只能登录已有账号,不能顺带注册:注册统一走「邮箱+密码+邀请码」。 // 顺带的好处:陌生邮箱来要码时直接静默返回,不发信、也不泄漏邮箱是否注册过。 disableSignUp: true, otpLength: 6, expiresIn: 600, // 10 分钟 allowedAttempts: 3, // 验证码最多试 3 次,超出作废,防止暴力猜码 async sendVerificationOTP({ email, otp, type }) { await sendOtpEmail(email, otp, otpLabel(type)) }, }), organization(), nextCookies(), ], })