feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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 || '注册失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user