Files
NextEdu/src/modules/auth/actions.ts
SpecialX 12a766d3ee refactor(logging): replace console.* with module loggers in server modules
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)。
2026-07-07 12:34:57 +08:00

378 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 ActionP0-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)
}