34 个服务端文件替换 86 处 console.* 调用为 createModuleLogger。模块覆盖: exams/audit/school/files/classes/notifications/grades/auth/homework/announcements/settings/lesson-preparation。shared lib: cache/redis-store, rate-limit/redis-limiter, redis-client, exam-homework-port。API routes: web-vitals, cron/audit-cleanup, proctoring/event。 web-vitals/route.ts 从 edge runtime 改为 nodejs runtime,因 pino 依赖 Node.js stream 内置模块,不兼容 Edge Runtime (V8 Isolate)。
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
"use server"
|
||
|
||
import { z } from "zod"
|
||
import { getTranslations } from "next-intl/server"
|
||
|
||
import type { ActionState } from "@/shared/types/action-state"
|
||
import { rateLimit, rateLimitKey } from "@/shared/lib/rate-limit"
|
||
import { resolveClientIp } from "@/shared/lib/http-utils"
|
||
import { logLoginEvent } from "@/shared/lib/login-logger"
|
||
import { trackAuthEvent, trackEvent } from "@/shared/lib/track-event"
|
||
import { checkBreachedPassword } from "@/shared/lib/breached-password"
|
||
|
||
import {
|
||
validateInvitationCode,
|
||
consumeInvitationCode,
|
||
} from "@/modules/invitation-codes/data-access"
|
||
import { RegisterSchema } from "./schema"
|
||
import { buildRegisterInput, createUser, isEmailAvailable } from "./data-access"
|
||
import { preflightTwoFactorByEmail } from "./services/two-factor-service"
|
||
import {
|
||
REGISTER_ERROR_CODES,
|
||
type RegisterErrorCode,
|
||
type RegisterResult,
|
||
} from "./types"
|
||
import { createModuleLogger } from "@/shared/lib/logger"
|
||
|
||
const log = createModuleLogger("auth")
|
||
|
||
/**
|
||
* 注册速率限制规则。
|
||
*
|
||
* 独立于 LOGIN 限制,避免恶意用户通过注册接口枚举邮箱或爆破邀请码。
|
||
* 规则:15 分钟内最多 5 次(与 LOGIN 一致,便于运维记忆)。
|
||
*/
|
||
const REGISTER_RATE_LIMIT = {
|
||
limit: 5,
|
||
windowMs: 15 * 60 * 1000,
|
||
} as const
|
||
|
||
/**
|
||
* 将原始数据库/系统错误归一化为 RegisterErrorCode。
|
||
*
|
||
* 不向客户端暴露 SQL 文案、表名或字段名等敏感信息。
|
||
*/
|
||
function classifyRegisterError(error: unknown): RegisterErrorCode {
|
||
if (error instanceof Error) {
|
||
const msg = error.message.toLowerCase()
|
||
if (error.message === "DEFAULT_ROLE_NOT_FOUND") {
|
||
return REGISTER_ERROR_CODES.DEFAULT_ROLE_MISSING
|
||
}
|
||
// 捕获 MySQL 唯一索引冲突(不暴露原始 sqlMessage)
|
||
if (
|
||
msg.includes("duplicate") ||
|
||
msg.includes("unique") ||
|
||
msg.includes("er_dup_entry")
|
||
) {
|
||
return REGISTER_ERROR_CODES.EMAIL_TAKEN
|
||
}
|
||
}
|
||
return REGISTER_ERROR_CODES.UNKNOWN
|
||
}
|
||
|
||
/**
|
||
* P1-1: 通过 next-intl 服务端翻译查找错误消息。
|
||
* i18n 命名空间:`auth.register.errors.{errorCode}`
|
||
*/
|
||
async function messageForError(
|
||
code: RegisterErrorCode
|
||
): Promise<string> {
|
||
const t = await getTranslations("auth.register")
|
||
return t(`errors.${code}`)
|
||
}
|
||
|
||
/**
|
||
* 注册 Server Action(P0-1:从 register/page.tsx 内联实现下沉到模块层)。
|
||
*
|
||
* 安全设计:
|
||
* - Zod 校验输入(含未成年人监护人条件校验)
|
||
* - 独立速率限制 key `register:{ip}`,与 LOGIN 限制隔离
|
||
* - 注册前查询邮箱可用性(不暴露 userId)
|
||
* - 数据库写入委托给 data-access.createUser(不直接操作 schema)
|
||
* - 成功/失败均写入 loginLogs 审计日志(action="signup")
|
||
* - 错误响应只返回结构化 errorCode,不暴露 SQL/堆栈信息
|
||
*
|
||
* 返回值:
|
||
* - 成功:`{ success: true, data: { userId } }`(userId 仅用于内部跟踪,前端可忽略)
|
||
* - 失败:`{ success: false, errorCode, message }`
|
||
*/
|
||
export async function registerAction(
|
||
formData: FormData
|
||
): Promise<ActionState<RegisterResult>> {
|
||
// 1) 速率限制(独立 key,避免与登录失败计数叠加)
|
||
const ip = await resolveClientIp()
|
||
const limitKey = rateLimitKey("register", ip)
|
||
const limitResult = await rateLimit({
|
||
key: limitKey,
|
||
limit: REGISTER_RATE_LIMIT.limit,
|
||
windowMs: REGISTER_RATE_LIMIT.windowMs,
|
||
})
|
||
|
||
if (!limitResult.success) {
|
||
// 速率限制命中也记录日志,便于运维识别攻击
|
||
const email = String(formData.get("email") ?? "")
|
||
await logLoginEvent({
|
||
userEmail: email,
|
||
action: "signup",
|
||
status: "failure",
|
||
errorMessage: "RATE_LIMIT_EXCEEDED",
|
||
}).catch(() => {
|
||
/* 日志失败不影响主流程 */
|
||
})
|
||
|
||
return {
|
||
success: false,
|
||
errorCode: REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
||
message: await messageForError(REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED),
|
||
}
|
||
}
|
||
|
||
// 2) 提取并校验表单字段
|
||
const raw = {
|
||
name: String(formData.get("name") ?? ""),
|
||
email: String(formData.get("email") ?? ""),
|
||
password: String(formData.get("password") ?? ""),
|
||
birthDate: String(formData.get("birthDate") ?? ""),
|
||
guardianName: String(formData.get("guardianName") ?? ""),
|
||
guardianPhone: String(formData.get("guardianPhone") ?? ""),
|
||
guardianRelation: String(formData.get("guardianRelation") ?? ""),
|
||
agreedTerms: formData.get("agreedTerms") === "true",
|
||
agreedGuardian: formData.get("agreedGuardian") === "true",
|
||
// audit-P2-3: 邀请码字段(可选)
|
||
invitationCode: String(formData.get("invitationCode") ?? ""),
|
||
}
|
||
|
||
const parsed = RegisterSchema.safeParse(raw)
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
errorCode: REGISTER_ERROR_CODES.VALIDATION_FAILED,
|
||
message: await messageForError(REGISTER_ERROR_CODES.VALIDATION_FAILED),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
}
|
||
}
|
||
|
||
const data = parsed.data
|
||
const normalizedEmail = data.email.toLowerCase()
|
||
|
||
// 3) 邮箱可用性预检查(避免直接触发数据库唯一约束,且能给出更友好错误码)
|
||
const available = await isEmailAvailable(normalizedEmail)
|
||
if (!available) {
|
||
await logLoginEvent({
|
||
userEmail: normalizedEmail,
|
||
action: "signup",
|
||
status: "failure",
|
||
errorMessage: "EMAIL_TAKEN",
|
||
}).catch(() => {
|
||
/* ignore */
|
||
})
|
||
|
||
return {
|
||
success: false,
|
||
errorCode: REGISTER_ERROR_CODES.EMAIL_TAKEN,
|
||
message: await messageForError(REGISTER_ERROR_CODES.EMAIL_TAKEN),
|
||
}
|
||
}
|
||
|
||
// audit-P2-4: Breached password 检测(HIBP k-anonymity API)
|
||
// fail-open 策略:API 不可用时 checkSkipped=true,不阻断注册流程
|
||
const breachCheck = await checkBreachedPassword(data.password)
|
||
if (breachCheck.isBreached) {
|
||
await logLoginEvent({
|
||
userEmail: normalizedEmail,
|
||
action: "signup",
|
||
status: "failure",
|
||
errorMessage: "BREACHED_PASSWORD",
|
||
}).catch(() => {
|
||
/* ignore */
|
||
})
|
||
|
||
return {
|
||
success: false,
|
||
errorCode: REGISTER_ERROR_CODES.BREACHED_PASSWORD,
|
||
message: await messageForError(REGISTER_ERROR_CODES.BREACHED_PASSWORD),
|
||
}
|
||
}
|
||
|
||
// audit-P2-3: 邀请码校验(可选)
|
||
// 若提供邀请码,校验通过后使用邀请码中的 role 覆盖默认 student。
|
||
// 校验失败返回结构化错误码(不暴露具体失败原因防枚举)。
|
||
let invitedRole: string | undefined
|
||
let invitedClassId: string | undefined
|
||
let invitationCodeForConsume: string | undefined
|
||
|
||
if (data.invitationCode && data.invitationCode.length > 0) {
|
||
const codeValue = data.invitationCode.toUpperCase()
|
||
invitationCodeForConsume = codeValue
|
||
const validationResult = await validateInvitationCode(codeValue, normalizedEmail)
|
||
|
||
if (!validationResult.valid) {
|
||
await logLoginEvent({
|
||
userEmail: normalizedEmail,
|
||
action: "signup",
|
||
status: "failure",
|
||
errorMessage: "INVITATION_CODE_INVALID",
|
||
}).catch(() => {
|
||
/* ignore */
|
||
})
|
||
|
||
return {
|
||
success: false,
|
||
errorCode: REGISTER_ERROR_CODES.INVITATION_CODE_INVALID,
|
||
message: await messageForError(REGISTER_ERROR_CODES.INVITATION_CODE_INVALID),
|
||
}
|
||
}
|
||
|
||
if (validationResult.code) {
|
||
invitedRole = validationResult.code.role
|
||
invitedClassId = validationResult.code.classId ?? undefined
|
||
}
|
||
}
|
||
|
||
// 4) 调用 data-access 创建用户(含角色分配,但不自动创建角色)
|
||
const input = buildRegisterInput({
|
||
name: data.name,
|
||
email: normalizedEmail,
|
||
password: data.password,
|
||
birthDate: data.birthDate && data.birthDate.length > 0 ? data.birthDate : null,
|
||
guardianName: data.guardianName ?? "",
|
||
guardianPhone: data.guardianPhone ?? "",
|
||
guardianRelation: data.guardianRelation ?? "",
|
||
role: invitedRole,
|
||
classId: invitedClassId,
|
||
})
|
||
|
||
const t = await getTranslations("auth.register")
|
||
|
||
try {
|
||
const result = await createUser(input)
|
||
|
||
// audit-P2-3: 注册成功后标记邀请码为已使用(乐观锁,避免并发使用)
|
||
// 使用失败不阻断注册流程(用户已创建),仅记录日志
|
||
if (invitationCodeForConsume) {
|
||
const consumed = await consumeInvitationCode({
|
||
code: invitationCodeForConsume,
|
||
email: normalizedEmail,
|
||
userId: result.userId,
|
||
}).catch(() => false)
|
||
|
||
if (!consumed) {
|
||
// 邀请码已被他人使用(极端并发场景)
|
||
log.warn(
|
||
{
|
||
invitationCode: invitationCodeForConsume,
|
||
email: normalizedEmail,
|
||
},
|
||
"Invitation code was already consumed by another user",
|
||
)
|
||
} else {
|
||
void trackEvent({
|
||
event: "invitation_codes.consumed",
|
||
userId: result.userId,
|
||
properties: {
|
||
email: normalizedEmail,
|
||
role: invitedRole,
|
||
hasClassId: Boolean(invitedClassId),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
// 5) 成功审计日志
|
||
await logLoginEvent({
|
||
userId: result.userId,
|
||
userEmail: normalizedEmail,
|
||
action: "signup",
|
||
status: "success",
|
||
}).catch(() => {
|
||
/* 日志失败不影响注册成功 */
|
||
})
|
||
|
||
// audit-P1-9:注册成功埋点(用于注册转化率、注册渠道分析)
|
||
// 非阻塞:trackEvent 内部已吞掉异常
|
||
await trackAuthEvent("auth.signup", {
|
||
userId: result.userId,
|
||
properties: {
|
||
email: normalizedEmail,
|
||
role: input.role,
|
||
isMinor: input.isMinor,
|
||
usedInvitationCode: Boolean(invitationCodeForConsume),
|
||
},
|
||
})
|
||
|
||
return {
|
||
success: true,
|
||
data: result,
|
||
message: t("toast.createSuccess"),
|
||
}
|
||
} catch (error) {
|
||
// 6) 错误分类(不向客户端暴露原始错误信息)
|
||
const errorCode = classifyRegisterError(error)
|
||
|
||
await logLoginEvent({
|
||
userEmail: normalizedEmail,
|
||
action: "signup",
|
||
status: "failure",
|
||
errorMessage: errorCode,
|
||
}).catch(() => {
|
||
/* ignore */
|
||
})
|
||
|
||
return {
|
||
success: false,
|
||
errorCode,
|
||
message: await messageForError(errorCode),
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 邮箱可用性查询 Server Action。
|
||
*
|
||
* 用于注册表单实时检查邮箱是否已被占用。限流复用 REGISTER 规则,
|
||
* 避免被用于邮箱枚举攻击。
|
||
*/
|
||
export async function checkEmailAvailabilityAction(
|
||
email: string
|
||
): Promise<ActionState<{ available: boolean }>> {
|
||
const ip = await resolveClientIp()
|
||
const limitKey = rateLimitKey("register", ip)
|
||
const limitResult = await rateLimit({
|
||
key: limitKey,
|
||
limit: REGISTER_RATE_LIMIT.limit,
|
||
windowMs: REGISTER_RATE_LIMIT.windowMs,
|
||
})
|
||
|
||
if (!limitResult.success) {
|
||
return {
|
||
success: false,
|
||
errorCode: REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
||
message: await messageForError(REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED),
|
||
}
|
||
}
|
||
|
||
const emailSchema = z.string().trim().email()
|
||
const parsed = emailSchema.safeParse(email)
|
||
if (!parsed.success) {
|
||
const t = await getTranslations("auth.register")
|
||
return {
|
||
success: false,
|
||
errorCode: REGISTER_ERROR_CODES.VALIDATION_FAILED,
|
||
message: t("emailFormatError"),
|
||
}
|
||
}
|
||
|
||
const available = await isEmailAvailable(parsed.data.toLowerCase())
|
||
return {
|
||
success: true,
|
||
data: { available },
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 2FA 预检 Server Action(薄封装,供 LoginForm 客户端组件调用)。
|
||
*
|
||
* 委托给 `services/two-factor-service.preflightTwoFactorByEmail`,
|
||
* LoginForm 不再跨模块依赖 `modules/settings/actions-security`,
|
||
* 符合 audit-P0-2 同模块依赖原则。
|
||
*
|
||
* 返回值:
|
||
* - `{ required: true }` — 用户启用了 2FA,登录表单应展示 2FA 输入框
|
||
* - `{ required: false }` — 用户未启用 2FA 或不存在(防邮箱枚举)
|
||
*/
|
||
export async function preflightTwoFactorAction(
|
||
email: string
|
||
): Promise<{ required: boolean }> {
|
||
return preflightTwoFactorByEmail(email)
|
||
}
|