feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, users, userIdentities } from '~/drizzle/db'
|
||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
import { validateOAuthRegisterCredentials } from '~/utils/oauth-register'
|
||||
import { isSecureRequest } from '~~/server/utils/request-utils'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { createAuthSession } from '~~/server/utils/auth-session'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { resolveGradeClassError, REMARK_MAX_LENGTH } from '~~/server/utils/register-validation'
|
||||
import { isGradeClassValid } from '~~/server/utils/grade-class-options'
|
||||
import { getIdentityAvatarUrl } from '~~/server/utils/user-avatar'
|
||||
import { verifyEmailCode } from '~~/server/utils/email-verification'
|
||||
import { notifyRegistration } from '~~/server/utils/registration-notify'
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export default defineEventHandler(async (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, '数据库服务暂时不可用')
|
||||
}
|
||||
|
||||
// 检查是否允许 OAuth 注册
|
||||
const config = await db.query.systemSettings.findFirst()
|
||||
if (!config?.allowOAuthRegistration) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.AUTH_OAUTH_REGISTER_DISABLED_BIND, '系统已关闭第三方账号注册功能,请登录现有账号进行绑定')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { password, confirmPassword } = body
|
||||
const username = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
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() : ''
|
||||
const bindingToken = getCookie(event, 'binding-token')
|
||||
|
||||
// 邮箱必填由管理员开关控制(与本地注册同源):开启时邮箱必填;验证码在用户名检查后消费
|
||||
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} 个字符`)
|
||||
}
|
||||
if (!bindingToken) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.AUTH_REGISTER_SESSION_EXPIRED, '注册会话已过期,请重新通过第三方登录发起')
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = verifyBindingToken(bindingToken)
|
||||
} catch (e) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
throw createApiError(400, SERVER_ERROR_CODES.AUTH_INVALID_REGISTER_TOKEN, '无效的注册令牌')
|
||||
}
|
||||
|
||||
// 验证输入
|
||||
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)
|
||||
}
|
||||
|
||||
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, '请选择系统内已存在的年级和班级')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
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, '用户名已存在,请使用其他用户名')
|
||||
}
|
||||
|
||||
// 检查OAuth身份是否已被绑定
|
||||
const existingIdentity = await db.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) =>
|
||||
and(eq(t.provider, payload.provider), eq(t.providerUserId, payload.providerUserId))
|
||||
})
|
||||
|
||||
if (existingIdentity) {
|
||||
throw createApiError(409, SERVER_ERROR_CODES.AUTH_OAUTH_ALREADY_BOUND, '该第三方账号已被绑定,请直接登录或绑定到现有账户')
|
||||
}
|
||||
|
||||
// 邮箱验证码在最后校验(前置校验通过后消费)
|
||||
if (email && !(await verifyEmailCode(email, emailCode))) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_INVALID, '邮箱验证码无效或已过期,请重新获取')
|
||||
}
|
||||
|
||||
try {
|
||||
// 开事务创建用户和关联身份
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
const now = getServerDate()
|
||||
const avatarUrl = getIdentityAvatarUrl({
|
||||
provider: payload.provider,
|
||||
providerUserId: payload.providerUserId,
|
||||
providerUsername: payload.providerUsername,
|
||||
avatar: payload.avatar
|
||||
})
|
||||
|
||||
// 创建用户(onConflictDoNothing 兜底并发用户名竞态)
|
||||
const insertedUser = (await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
username,
|
||||
name,
|
||||
grade: selectedGrade || null,
|
||||
class: selectedClass || null,
|
||||
password: hashedPassword,
|
||||
role: 'USER',
|
||||
status: config?.oauthRegisterRequiresApproval ? 'pending' : 'active',
|
||||
remark: remark || null,
|
||||
email: email || null,
|
||||
emailVerified: email ? true : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
passwordChangedAt: now,
|
||||
lastLogin: now,
|
||||
forcePasswordChange: false,
|
||||
avatarProvider: avatarUrl ? payload.provider : null,
|
||||
avatarProviderUserId: avatarUrl ? payload.providerUserId : null
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: users.id, tokenVersion: users.tokenVersion }))[0]
|
||||
|
||||
if (!insertedUser) {
|
||||
throw createApiError(409, SERVER_ERROR_CODES.AUTH_USERNAME_TAKEN, '用户名已存在,请使用其他用户名')
|
||||
}
|
||||
|
||||
// 关联OAuth身份
|
||||
await tx.insert(userIdentities).values({
|
||||
userId: insertedUser.id,
|
||||
provider: payload.provider,
|
||||
providerUserId: payload.providerUserId,
|
||||
providerUsername: payload.providerUsername,
|
||||
avatar: payload.avatar || null,
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return insertedUser
|
||||
})
|
||||
|
||||
// 清除绑定令牌
|
||||
deleteCookie(event, 'binding-token')
|
||||
|
||||
// 注册通知(站内通知管理员 + 邮件;异步,失败不影响主流程)
|
||||
void notifyRegistration(result.id, username, name, email, Boolean(config?.oauthRegisterRequiresApproval))
|
||||
|
||||
// 需要审核时:不签发登录态,等待管理员审核
|
||||
if (config?.oauthRegisterRequiresApproval) {
|
||||
return {
|
||||
success: true,
|
||||
pendingApproval: true
|
||||
}
|
||||
}
|
||||
|
||||
// 生成JWT令牌
|
||||
const { token } = await createAuthSession(event, { id: result.id, role: 'USER', tokenVersion: result.tokenVersion }, payload.provider || 'oauth')
|
||||
|
||||
// 自动判断是否需要secure
|
||||
const isSecure = isSecureRequest(event)
|
||||
|
||||
// 设置cookie
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7天
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: result.id,
|
||||
username: username,
|
||||
role: 'USER'
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 业务错误码(如用户名冲突 409)直接透传
|
||||
if (e?.statusCode) throw e
|
||||
console.error('OAuth register error:', e)
|
||||
throw createApiError(500, SERVER_ERROR_CODES.AUTH_SYSTEM_ERROR, e.message || '注册失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user