feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
import bcrypt from 'bcryptjs'
import { eq, inArray } from 'drizzle-orm'
import { db, users } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { validateOAuthRegisterCredentials } from '~/utils/oauth-register'
import { isSecureRequest } from '~~/server/utils/request-utils'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { getClientIP } from '~~/server/utils/ip-utils'
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
import { getServerDate, getServerTimestamp } from '~~/server/utils/serverTime'
import { verifyAndConsumeCaptcha } from '~~/server/utils/captcha'
import { resolveGradeClassError, REMARK_MAX_LENGTH } from '~~/server/utils/register-validation'
import { isGradeClassValid } from '~~/server/utils/grade-class-options'
import { verifyEmailCode } from '~~/server/utils/email-verification'
import { notifyRegistration } from '~~/server/utils/registration-notify'
const REGISTER_RATE_LIMIT = 5
const REGISTER_RATE_WINDOW_MS = 60 * 60 * 1000
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const clientIp = getClientIP(event)
// 数据库连接检查(在读取配置前执行,DB 故障时返回明确的 503 兜底)
try {
await db.select().from(users).limit(1)
} catch (error) {
console.error('Database connection error:', error)
throw createApiError(503, SERVER_ERROR_CODES.AUTH_DATABASE_UNAVAILABLE, '数据库服务暂时不可用')
}
const config = await db.query.systemSettings.findFirst()
// 开关:是否开放用户名密码注册
if (!config?.allowRegister) {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_REGISTER_DISABLED, '系统未开放注册')
}
// 限流:按 IP 每小时最多 5 次注册请求
const limitResult = await checkDistributedRateLimit(`register:${clientIp}`, REGISTER_RATE_LIMIT, REGISTER_RATE_WINDOW_MS)
if (!limitResult.isAllowed) {
const waitMinutes = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 60000)
throw createApiError(429, SERVER_ERROR_CODES.AUTH_RATE_LIMITED_MINUTES, `注册请求过于频繁,请等待 ${waitMinutes} 分钟后再试`, { params: [waitMinutes] })
}
// 验证码:开启验证码服务时注册必须通过(图形验证码或 Turnstile)
const captchaEnabled = Boolean(config?.captchaEnabled)
const captchaProvider = config?.captchaProvider || 'graphic'
if (captchaEnabled) {
if (captchaProvider === 'turnstile') {
const turnstileSecretKey = config?.turnstileSecretKey || ''
const turnstileToken = body.turnstileToken
if (!turnstileSecretKey) {
throw createApiError(500, SERVER_ERROR_CODES.AUTH_CAPTCHA_SERVICE_UNAVAILABLE, '验证码服务配置错误,请联系管理员')
}
if (!turnstileToken) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_CAPTCHA_REQUIRED, '请完成人机验证', { captchaRequired: true, captchaProvider: 'turnstile' })
}
const verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const formData = new URLSearchParams()
formData.append('secret', turnstileSecretKey)
formData.append('response', turnstileToken)
formData.append('remoteip', clientIp)
try {
const result: any = await $fetch(verifyUrl, {
method: 'POST',
body: formData,
timeout: 5000
})
if (!result.success) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_CAPTCHA_FAILED_OR_EXPIRED, '人机验证失败或已过期,请重试', { captchaRequired: true, captchaProvider: 'turnstile' })
}
} catch (err: any) {
if (err.statusCode === 400) throw err
console.error('Turnstile verification error:', err)
throw createApiError(500, SERVER_ERROR_CODES.AUTH_CAPTCHA_SERVICE_UNAVAILABLE, '人机验证服务暂时不可用')
}
} else {
const captchaId = body.captchaId
const captchaInput = body.captchaInput
if (!captchaId || !captchaInput) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_IMAGE_CAPTCHA_REQUIRED, '请完成图形验证码', { captchaRequired: true, captchaProvider: 'graphic' })
}
const isValid = await verifyAndConsumeCaptcha(captchaId, captchaInput)
if (!isValid) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_CAPTCHA_FAILED_OR_EXPIRED, '验证码错误或已过期,请重新输入', { captchaRequired: true, captchaProvider: 'graphic' })
}
}
}
// 字段校验
const username = typeof body.username === 'string' ? body.username.trim() : ''
const name = typeof body.name === 'string' ? body.name.trim() : ''
const password = typeof body.password === 'string' ? body.password : ''
const confirmPassword = typeof body.confirmPassword === 'string' ? body.confirmPassword : ''
const selectedGrade = typeof body.grade === 'string' ? body.grade.trim() : ''
const selectedClass = typeof body.class === 'string' ? body.class.trim() : ''
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
const emailCode = typeof body.emailCode === 'string' ? body.emailCode.trim() : ''
if (!username || !name || !password || !confirmPassword) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_NAME_USERNAME_PASSWORD_REQUIRED, '姓名、用户名、密码不能为空')
}
const validationError = validateOAuthRegisterCredentials(username, password, confirmPassword)
if (validationError) {
throw createApiError(400, validationError.code, validationError.message)
}
// 邮箱必填由管理员开关控制(需 SMTP 已配置方可开启):开启时邮箱必填
const emailRequired = Boolean(config?.registerEmailRequired)
if (emailRequired && !email) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请填写邮箱地址')
}
// 邮箱格式校验(验证码校验在用户名唯一性检查之后执行,避免失败即烧码)
if (email && !EMAIL_REGEX.test(email)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请输入有效的邮箱地址')
}
if (remark.length > REMARK_MAX_LENGTH) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `备注不能超过 ${REMARK_MAX_LENGTH} 个字符`)
}
const gradeClassError = resolveGradeClassError(
selectedGrade,
selectedClass,
Boolean(config?.registerRequiresGradeClass)
)
if (gradeClassError) {
throw createApiError(400, gradeClassError.code, gradeClassError.message)
}
if (selectedGrade && selectedClass) {
const valid = await isGradeClassValid(selectedGrade, selectedClass)
if (!valid) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_GRADE_CLASS_MUST_EXIST, '请选择系统内已存在的年级和班级')
}
}
// 用户名唯一性检查(预查 + 插入 onConflictDoNothing 兜底并发竞态)
const existingUser = await db.query.users.findFirst({
where: (t, { eq }) => eq(t.username, username)
})
if (existingUser) {
throw createApiError(409, SERVER_ERROR_CODES.AUTH_USERNAME_TAKEN, '用户名已存在,请使用其他用户名')
}
// 邮箱验证码在最后校验(此时用户名等前置校验均已通过,失败即消费不会误烧码)
if (email && !(await verifyEmailCode(email, emailCode))) {
throw createApiError(400, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_INVALID, '邮箱验证码无效或已过期,请重新获取')
}
const requiresApproval = Boolean(config?.registerRequiresApproval)
try {
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10)
const now = getServerDate()
// 创建用户
const insertedUser = (await db
.insert(users)
.values({
username,
name,
grade: selectedGrade || null,
class: selectedClass || null,
password: hashedPassword,
role: 'USER',
status: requiresApproval ? 'pending' : 'active',
remark: remark || null,
email: email || null,
emailVerified: email ? true : null,
createdAt: now,
updatedAt: now,
passwordChangedAt: now,
lastLogin: now,
forcePasswordChange: false
})
.onConflictDoNothing()
.returning({ id: users.id, tokenVersion: users.tokenVersion }))[0]
if (!insertedUser) {
throw createApiError(409, SERVER_ERROR_CODES.AUTH_USERNAME_TAKEN, '用户名已存在,请使用其他用户名')
}
// 需要审核:不签发登录态,等待管理员审核
// 注册通知(异步,不阻塞主流程)
void notifyRegistration(insertedUser.id, username, name, email, requiresApproval)
if (requiresApproval) {
return {
success: true,
pendingApproval: true
}
}
// 无需审核:直接登录
const token = JWTEnhanced.generateToken(insertedUser.id, 'USER', insertedUser.tokenVersion)
const isSecure = isSecureRequest(event)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
path: '/'
})
return {
success: true,
user: {
id: insertedUser.id,
username: username,
role: 'USER'
}
}
} catch (e: any) {
// 业务错误码(如用户名冲突 409)直接透传
if (e?.statusCode) throw e
console.error('Register error:', e)
throw createApiError(500, SERVER_ERROR_CODES.AUTH_SYSTEM_ERROR, e.message || '注册失败,请稍后重试')
}
})